diff --git a/.changeset/render-record-and-values-option.md b/.changeset/render-record-and-values-option.md new file mode 100644 index 000000000..1b27f8754 --- /dev/null +++ b/.changeset/render-record-and-values-option.md @@ -0,0 +1,17 @@ +--- +"@solidjs/signals": patch +"solid-js": patch +"@solidjs/web": patch +--- + +`"render"` record and `AttributionOptions.values` — the two remaining places where the observe surface duplicated a record or scrubbed one after the fact. + +**`"render"` record** (`@solidjs/web`, server). A server render — `renderToString` or `renderToStream` — is now a record on `OBSERVE.records`: `RenderEvent { mode: "string" | "stream", at, shellMs?, durationMs, boundaries, outcome: "complete" | "abandoned" | "error" }`, delivered when the render ends, with `RenderLive { event?: RequestEvent, trace: TraceContext }` beside it. `shellMs` is render start → the shell complete (the stream's shell handed to the sink; the string's document assembled); `boundaries` counts the `` boundaries the shell waited on. Types `RenderEvent`, `RenderLive`, `RenderListener` are exported from `@solidjs/web`. + +The response's `Server-Timing` metrics are now strictly projections of records, one gate each (`observed(type) || dev`): `solid-invocation` from the `"invocation"` record, `solid-shell` from the `"render"` record's `shellMs`, `solid-boundary` from each `"boundary"` record the shell waited on — computed from the record objects at head commit, no second push. Wire format unchanged. **Behavior change (observe tier):** `solid-shell` now rides the `"render"` listener, not the `"boundary"` listener; an observe deployment that subscribed to `"boundary"` alone keeps its `solid-boundary` metrics and needs a `"render"` subscription for `solid-shell`. Dev builds still write all three always. + +**`AttributionOptions.values: "full" | "labels" | "none"`** (`@solidjs/signals`, re-exported by `solid-js/attribution`). One engine option governs the user-data fields of the engine's records at the source: `ChangeRecord.prev`/`value`, `HeldWrite.prev`/`value`, `ChangeOrigin.target` (and so `InteractionEvent.target`, `HoldEvent.interaction.target`), and every sentence built from them (`formatRerun`, `formatOrigin`, `SILENT_HOLD`/`LONG_HOLD`, `OPTIMISTIC_REVERTED`). `"full"` is today's dev output; `"labels"` drops value previews and keeps element text only on a `button` or an `a`; `"none"` drops both. **The default is the build tier's: `"full"` in dev builds, `"none"` in observe builds** (folded at build time — the observe engine ships `"none"` only). Across holds the **least permissive** level wins; a holder naming no level asks for the tier's default, so in an observe build it tightens to `"none"` beside anyone, while a single holder passing `"full"` there gets `"full"`; an explicit `"full"` never loosens what another holder demanded. Observe-tier consumers that export records should pass their level explicitly and treat it as their export contract. + +**Removed:** `PerformanceTracksOptions.scrub` and the adapter's scrub helpers. `@solidjs/web/performance-tracks` paints what the engine put on the record: an observe build's tracks inherit `"none"` (tighter than the old scrub — no element text on buttons/links either); the old observe posture is `enablePerformanceTracks({ attribution: { values: "labels" } })`. **Behavior change:** a finding's marker always carries `event.message`. + +Internal: `solid-js`'s server render context seam `_timing` became `_recordBoundary(event: BoundaryEvent)` — the boundary files its record, the web runtime projects the header from it. diff --git a/documentation/plans/chrome-performance-tracks-plan.md b/documentation/plans/chrome-performance-tracks-plan.md index 4ed627aeb..716841d3a 100644 --- a/documentation/plans/chrome-performance-tracks-plan.md +++ b/documentation/plans/chrome-performance-tracks-plan.md @@ -169,7 +169,17 @@ transition)`. Shape: `{ ownerPath?, at, shownMs, interaction? }` (item 4's `47 runs`. - PII: observe builds apply the sketch §6 scrub by default (no value previews; element text only on a `button`/`a`; a finding's sentence - dropped); dev shows everything. + dropped); dev shows everything. _As landed (public-API consolidation, + PR 3):_ the adapter's `scrub` option and its scrub helpers were removed; + the same posture is now the engine's `AttributionOptions.values` + (`"full"` | `"labels"` — the old observe scrub | `"none"`; the default is + the tier's: `"full"` in dev, `"none"` in observe), applied at the source + when the record is built, least permissive level winning across holds. + The adapter paints what the record carries — an observe build's tracks + inherit `"none"`; pass + `enablePerformanceTracks({ attribution: { values: "labels" } })` for the + old observe posture. A finding's marker always carries `event.message` + (a message from a non-engine emitter is not the engine's to govern). - Clock quantization (08-dev-diagnostics): without cross-origin isolation many `Effects`/`Memos` spans are zero-width. Never dropped; the wall-clock tracks carry the meaning. @@ -248,6 +258,16 @@ gate `ssrLoadingBoundary` has.) No new server API: an observe deployment that wants server spans in the panel installs an observer, the same way it gets the trace advertised. +_As landed (public-API consolidation, PR 3):_ each metric is a projection +of one record object, read at head commit (`appendTraceServerTiming` over +`TraceRecord.timing` / `TraceRecord.render`), not a second push beside the +record. `solid-shell` got its own record — `"render"` (`RenderEvent`: `mode`, +`at`, `shellMs`, `durationMs`, `boundaries`, `outcome`; live `event`, +`trace`) — and its own gate, `observed("render") || IS_DEV`, instead of +riding the boundary listener; `shellMs` is stamped where the shell actually +completes (the stream's `doShell`, the string's assembled document) rather +than at stub commit. The wire format is unchanged. + **Client — the adapter.** Nothing new on the wire from the browser and no change to the `call` record: the adapter reads the metrics off `CallLive.response` (the transport's own `Response`; same-origin headers are diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index c084dda8f..476a6d5bd 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -762,7 +762,7 @@ OBSERVE.records.observed("invocation"); // true while a listener is subscribed The channel is `@solidjs/signals`'s, created once per **process** and registered on `globalThis` under `Symbol.for("@solidjs/signals/observe/records")`. Two consequences an observer can rely on: it exists as soon as `import { OBSERVE } from "solid-js"` (or from the core) resolves — an APM's `init()` can subscribe before the runtimes that emit have loaded, and without importing them — and a host that bundles the runtime into its server build and instruments through a `--import`ed module still finds one listener set across both copies. The same registration is how the wire layers emit: `@solidjs/web`'s server-function client is bundled without a framework import (a router or a non-Solid caller can use it), so it reaches the channel by the registered name rather than importing `solid-js`. Same tiers as the rest of `OBSERVE`: present in dev and observe builds, absent in prod — the prod artifacts fold the channel and every emit site out, and an emitter with no listener reads no clock. Listeners are observers: a throwing listener is reported through `console.error` and the call, the render, the stream and the other listeners are unaffected; nothing a listener does reaches the result. Delivery allocates nothing — the channel keeps one listener array per type, replaced (never mutated) on subscribe and unsubscribe, so an emit in progress finishes over the array it started with and a listener unsubscribing mid-delivery neither skips nor double-calls anyone that round. A subscription is the channel's, not any emitter's: it outlives the attribution engine's `enable()`/`disable()` cycles and is dropped only by the function `subscribe` returned. This is the seam for tooling that watches the app — APM adapters, devtools — and deliberately not a policy hook: `configureServerFunctionsServer({ wrapInvocation })` remains the single, last-writer-wins wrap around execution for code that must **change** a call, and an observer that installed itself there would either displace the host's policy or be displaced by it. Subscribe here, wrap there. -The types layer the way the packages do, each augmenting only the one beneath it: `@solidjs/signals` declares `RecordTypes`, extending `HostRecordTypes`, with the attribution engine's ten entries on it directly — the engine ships in that package, behind its own entry — `rerun`, `create`, `effect`, `flush`, `flight`, `fallback`, `interaction`, `hold`, `navigation`, `graph`; `solid-js` augments `RecordTypes` with its records (`"boundary"`, `"recovery"`); `@solidjs/web` augments `HostRecordTypes`, through `declare module "solid-js"`, with what it emits (`"invocation"`, `"call"`, `"frame"`). So `OBSERVE.records.subscribe(…)` types with the engine's records and every loaded runtime's from a single `solid-js` import, and each interface has exactly one augmenter (TypeScript merges an augmentation onto the declaration its alias resolves to; two packages augmenting one interface through different aliases would not both land). Five runtime records so far, beside the engine's ten (described under [Run attribution](#run-attribution--why-did-this-run); none is emitted until `attribution.enable()`). `OBSERVE.records.observed(type)` is the one gate an emitter of either kind consults before building a record. +The types layer the way the packages do, each augmenting only the one beneath it: `@solidjs/signals` declares `RecordTypes`, extending `HostRecordTypes`, with the attribution engine's ten entries on it directly — the engine ships in that package, behind its own entry — `rerun`, `create`, `effect`, `flush`, `flight`, `fallback`, `interaction`, `hold`, `navigation`, `graph`; `solid-js` augments `RecordTypes` with its records (`"boundary"`, `"recovery"`); `@solidjs/web` augments `HostRecordTypes`, through `declare module "solid-js"`, with what it emits (`"invocation"`, `"render"`, `"call"`, `"frame"`). So `OBSERVE.records.subscribe(…)` types with the engine's records and every loaded runtime's from a single `solid-js` import, and each interface has exactly one augmenter (TypeScript merges an augmentation onto the declaration its alias resolves to; two packages augmenting one interface through different aliases would not both land). Six runtime records so far, beside the engine's ten (described under [Run attribution](#run-attribution--why-did-this-run); none is emitted until `attribution.enable()`). `OBSERVE.records.observed(type)` is the one gate an emitter of either kind consults before building a record. The **`"boundary"` record** (from `solid-js`, server) is one `` boundary that **waited** during a server render: @@ -790,6 +790,18 @@ const off = OBSERVE.records.subscribe("invocation", (event, live) => { One record per call, delivered when the call **settles** — synchronously for a synchronous direct call, at resolution for a promise. `id` is the function's registered id; `direct` says whether this was an in-process SSR call (`true`, no `request`) or HTTP dispatch (`false`, `request` is the `Request` the handler dispatched). `outcome: "error"` carries the value **as thrown** in `live.error` — the sanitized `Error` the wire gets in production is the client's view, not the observer's. `deferred: true` marks a result the caller drives after the record (a stream or async generator): `durationMs` then measures to the handoff, not to the last chunk. For a direct call made during a `` boundary's render pass, `boundary` is that boundary's hydration id — the `"boundary"` record's `id` — so a boundary's wait reads as the server-function calls it consisted of; absent for a call outside any boundary's pass (the shell) and for HTTP dispatch. +The **`"render"` record** (from `@solidjs/web`, server) is one server render — a `renderToString` or a `renderToStream` — the server-side account of the head's timing: + +```js +const off = OBSERVE.records.subscribe("render", (event, live) => { + // event: { mode: "string" | "stream", at, shellMs?, durationMs, boundaries, + // outcome: "complete" | "abandoned" | "error" } + // live: { event?: RequestEvent, trace: TraceContext } +}); +``` + +One record per render, delivered when it **ends**: the document returned, the stream's last fragment written, or the render torn down. `at` is `performance.now()` at the render's start; `shellMs` runs start → the shell complete — for a stream, the head and shell handed to the sink (the head is frozen from there; a fragment can no longer add to it), for a string, the document assembled (the whole render) — and is absent when the render ended before its shell; `durationMs` runs start → the end. `boundaries` counts the `` boundaries the shell **waited on** — each also a `"boundary"` record with `streamed: false` — and not the ones that streamed after it; it is counted from the boundary records the render filed, so in an observe build it needs a `"boundary"` listener too (`0` with a `"render"` listener alone). `outcome` is `"complete"` for a render that ran to its end, `"abandoned"` when the consumer left mid-stream (the `SSR_STREAM_ABANDONED` finding is that request's account), `"error"` when the render failed (a string render threw; a stream's uncontained failure wound it down). `live.event` is the request the render served, absent for a render outside a request scope; `live.trace` is the render's trace context (what `getTraceContext()` answers during it). `solid-shell` on the response's `Server-Timing` is this record's `shellMs`, read off the same object at head commit (below, [Chrome Performance panel](#chrome-performance-panel-solidjswebperformance-tracks)). The cost is paid only with a listener installed (or in dev): a render nobody observes reads no clock. + The **`"call"` record** (from `@solidjs/web`, client) is one server-function call made from the browser — the invocation's twin, seen from the caller's end: ```js @@ -1048,7 +1060,13 @@ createRoot(() => { **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). The signals and stores created under it are excluded subjects wherever their writes come from — a click handler, an adapter callback — so writes need no `runWithOwner`, and must not use one: a write under an owner is a write in an owned scope (`REACTIVE_WRITE_IN_OWNED_SCOPE`). `OBSERVE.isExcluded(subject)` answers the question for any owner or node. -**Values in records — the PII surface.** Records name things (owner paths, `name` options, store paths, route patterns, function ids) and are otherwise numbers, kinds and outcomes; a handful of fields carry _user data_, and an exporter that leaves the process owns scrubbing them (vendors already have the control surface — `beforeSend`, `sendDefaultPii` — and the runtime keeps producing them because they are what makes dev output readable). The complete list: `ChangeRecord.prev`/`value` and `HeldWrite.prev`/`value` — previews of the written values (`preview()`: strings quoted and cut at 40 characters, numbers/booleans verbatim, everything else a type tag such as `Array(12)` or `[Object]`), so the string case is the one to drop or hash unless opted in; `ChangeOrigin.target` (and `InteractionRef.target`) — the element hit, `tag#id "text"` with up to 30 characters of `textContent` for anything that is not an `input`/`textarea`/`select`, so a label but also whatever a `` said; `ChangeOrigin.to`/`from`/`params` and `NavigationEvent.to`/`from`/`params` (`NavigationHop` too) — concrete paths and the values a route pattern bound (`/users/42`, `{ id: "42" }`), while `name` is the pattern; `DiagnosticEvent.message` and `data` for the responsiveness findings (`SILENT_HOLD`, `LONG_HOLD`) — the verdict sentence names the interaction (`click on button#next "Next →"`) and the navigation it was under (concrete `to`/`from`/`params`), and `data.interaction.target` / `data.navigation` carry the same fields structured; no finding quotes a value preview. `data.error` on the server error findings (`SSR_RENDER_ERROR_CONTAINED`, `SERVER_ERROR_SANITIZED` on either road) — the error **as thrown**, message and own properties, deliberately unsanitized: the wire got the generic message so the observer could see the real one, which means a driver's connection string or a query lands here, and an exporter treats it as it treats any captured exception. Dev-only checks may put the offending value on `data` (`PRELOAD_DESCRIPTOR_INVALID`'s `data.value`, `HEAD_TAG_INVALID`'s `data.detail`) — dev tier, never exported. Everything else is safe by construction: `RerunEvent` has names and numbers only; the runtimes' records (`"call"`, `"invocation"`, `"boundary"`, `"frame"`, `"recovery"`) never put arguments, results, thrown values, requests or responses on the record — those ride the `live` argument beside it, in-process only — and carry ids, methods, addresses, statuses and timings; `ownerPath` is component and primitive names. `stacks: true` adds first-party frames to `ChangeRecord.stack` (file paths, not values) and is a dev affordance to leave off in production. +**Values in records — the PII surface.** Records name things (owner paths, `name` options, store paths, route patterns, function ids) and are otherwise numbers, kinds and outcomes; a handful of fields carry _user data_. The engine governs those fields **at the source**, with one option on the hold — `attribution.enable({ values })` — so a record never carries what the level excludes and nothing downstream (a formatter, `@solidjs/web/performance-tracks`, an exporter) has to scrub. The fields the level governs: `ChangeRecord.prev`/`value` and `HeldWrite.prev`/`value` — previews of the written values (`preview()`: strings quoted and cut at 40 characters, numbers/booleans verbatim, everything else a type tag such as `Array(12)` or `[Object]`); `ChangeOrigin.target` (and so `InteractionEvent.target`, `HoldEvent.interaction.target`) — the element hit as the runtime described it, `tag#id "text"` with up to 30 characters of `textContent` for anything that is not an `input`/`textarea`/`select`, so a label but also whatever a `` said; and every sentence the engine builds from those — `formatRerun`, `formatOrigin`, the `SILENT_HOLD`/`LONG_HOLD` verdicts (`click on button#next "Next →" wrote "page" (1 → 2)…`, with `data.interaction.target` structured beside), `OPTIMISTIC_REVERTED` (which quotes the shown and settled values, `data.shown`/`data.truth`). The levels, each a strict subset of the one above: + +- `"full"` — **the dev default**: previews on every change record and held write; the element text on every target; the sentences quote both. Today's dev output, unchanged. Right for dev, a console session, a diagnostics capture an agent reads locally. +- `"labels"` — no value previews anywhere (`prev`/`value` absent, sentences say `wrote "page"` and `signal "n" write` without the `1 → 2`); element text kept only on a `button` or an `a` (`button#save "Save"` stays, `div#card "Personal note"` becomes `div#card`) — the control's label, never a cell's content. What `@solidjs/web/performance-tracks` used to apply by hand in observe builds. +- `"none"` — **the observe default**: no previews, no element text on any target (`button#save`); the sentences name the node and the element and nothing the person typed or read. + +**The default is the build tier's** — `"full"` in dev builds, `"none"` in observe builds (the literal is folded per build; the observe engine ships `"none"` only). An observe build is a production artifact: it carries no user data unless a holder asks, and a holder that wants more says so — `attribution.enable({ values: "labels" })` for the interaction's control label, `"full"` for everything. Across holds the **least permissive** level wins: a diagnostics panel asking for `"full"` beside an APM adapter asking for `"none"` gets `"none"` until the adapter releases — a production holder can rely on its level regardless of who else is on the engine. A holder that names no level asks for the tier's default, so in an observe build it tightens to `"none"` beside anyone, while a single holder passing `"full"` there gets `"full"`; an explicit `"full"` never loosens what another holder demanded. The level applies from the moment it is in effect (a record built before a stricter hold was taken keeps what it carried). An observe-tier consumer that ships records off the machine should treat its level as its export contract — pass it explicitly rather than relying on the default, and never scrub after the fact. Outside the level: `ChangeOrigin.to`/`from`/`params` and `NavigationEvent.to`/`from`/`params` (`NavigationHop` too) — concrete paths and the values a route pattern bound (`/users/42`, `{ id: "42" }`), while `name` is the pattern; the verdict sentences name the navigation with them. `data.error` on the server error findings (`SSR_RENDER_ERROR_CONTAINED`, `SERVER_ERROR_SANITIZED` on either road) — the error **as thrown**, message and own properties, deliberately unsanitized: the wire got the generic message so the observer could see the real one, which means a driver's connection string or a query lands here, and an exporter treats it as it treats any captured exception. Dev-only checks may put the offending value on `data` (`PRELOAD_DESCRIPTOR_INVALID`'s `data.value`, `HEAD_TAG_INVALID`'s `data.detail`) — dev tier, never exported. Everything else is safe by construction: `RerunEvent` has names and numbers only; the runtimes' records (`"call"`, `"invocation"`, `"render"`, `"boundary"`, `"frame"`, `"recovery"`) never put arguments, results, thrown values, requests or responses on the record — those ride the `live` argument beside it, in-process only — and carry ids, methods, addresses, statuses, counts and timings; `ownerPath` is component and primitive names. `stacks: true` adds first-party frames to `ChangeRecord.stack` (file paths, not values) and is a dev affordance to leave off in production. `costs()` aggregates since `enable()`: `scopes` ranked by self-time with `wastedMs` (time in runs whose value didn't change — the equality cutoff absorbed them), and `writes` ranked by the total downstream re-run time each root write caused. Overlay work (optimistic-lane and held runs — `phase: "optimistic" | "held"`) is accounted separately as `overlayMs` and never blamed as waste. @@ -1122,14 +1140,15 @@ import { enablePerformanceTracks } from "@solidjs/web/performance-tracks"; const disable = enablePerformanceTracks({ minMs: 0, // floor for run spans; 0 in dev, 0.05 in observe builds rich: true, // performance.measure with tooltips/properties (dev default) vs console.timeStamp - scrub: false, // drop value previews and element text (observe default) - attribution: {} // options for the engine hold it takes (log: false by default) + attribution: { values: "labels" } // options for the engine hold it takes (log: false; values: the tier's default) }); ``` +The adapter scrubs nothing itself: what the spans and tooltips say about values and elements is what the engine put on the records under the hold's `values` level (above) — the tier's default unless the `attribution` option names one: dev shows previews and element text (`"full"`), an observe build shows neither (`"none"`); pass `attribution: { values: "labels" }` for the control labels in an observe build, or `"none"` for a dev timeline without user data. + Group `Solid`, tracks in order: **Interactions** — the input delay, the handler, then the settle to `committed`/`held` (a silent hold as a warning); **Propagation** — one span per scheduler drain labelled by the writes that started it and what they reached (`count 0 → 1 — click on button#next · 5 runs, 1 unchanged`), with every run inside it beneath, labelled by what made it run (` › effect ← doubled`) — the write's path through the graph as a flame; **Effects** and **Memos** — one span per re-run, creation run (`· create`) and effect callback (`· callback`), coloured by self time, `warning` for a run that changed nothing; **Async** — flights kickoff → landing (abandoned ones as warnings) and fallbacks shown → hidden; **Holds** — each wait labelled by its blockers, `warning` when silent, `error` when long; **Navigations** — request → settle by route pattern; **Server** — server-function calls and frame streams. Every span is emitted retroactively from the record's own `performance.now()` stamps — nothing brackets a hot path. -The **Server** track also shows the server's side of the work it painted from the client: dev and observe servers write the request's timed work as `Server-Timing` metrics — `solid-invocation` on a server-function response, `solid-shell` and one `solid-boundary` per boundary that waited on a document (RFC 12, [the trace the request belongs to](12-ssr-http.md#the-trace-the-request-belongs-to-gettracecontext)) — and the adapter reads them back: a `call` record's `live.response` headers become ` · server` beneath the call span, placed so the server span ends at the resource's `responseStart` and runs back its `dur` (the gap before it is the request's wire; `Wire` is a property), with the document's own `PerformanceNavigationTiming.serverTiming` painted as `shell · server` and `boundary › · server` when the tracks enable. Resource entries can land after the call settles, so the adapter watches `PerformanceObserver({ type: "resource", buffered: true })` for up to 30 s per call and centres the span inside the call when no entry arrives. An observe-tier server writes the metrics only while something on the server is subscribed to its `invocation`/`boundary` records (a diagnostics capture, an APM adapter) — enabling the tracks in the browser does not by itself change a production-shaped wire; a dev server always writes them. +The **Server** track also shows the server's side of the work it painted from the client: dev and observe servers write the request's timed work as `Server-Timing` metrics, each a projection of one of the server's records on `OBSERVE.records` — `solid-invocation` from the `"invocation"` record on a server-function response; on a document, `solid-shell` from the `"render"` record's `shellMs` and one `solid-boundary` from each `"boundary"` record the shell waited on (RFC 12, [the trace the request belongs to](12-ssr-http.md#the-trace-the-request-belongs-to-gettracecontext)) — and the adapter reads them back: a `call` record's `live.response` headers become ` · server` beneath the call span, placed so the server span ends at the resource's `responseStart` and runs back its `dur` (the gap before it is the request's wire; `Wire` is a property), with the document's own `PerformanceNavigationTiming.serverTiming` painted as `shell · server` and `boundary › · server` when the tracks enable. Resource entries can land after the call settles, so the adapter watches `PerformanceObserver({ type: "resource", buffered: true })` for up to 30 s per call and centres the span inside the call when no entry arrives. An observe-tier server writes each metric only while something on the server is subscribed to the record it is projected from (`"render"` for the shell, `"boundary"` for the boundaries, `"invocation"` for the function — a diagnostics capture, an APM adapter) — enabling the tracks in the browser does not by itself change a production-shaped wire; a dev server always writes them. Labels read as source: a flow control's own nodes fold into its tag (` › ` rather than ` › › condition value`) and a composed primitive's nodes into the primitive (`createDebounced.value` → `createDebounced`, the same rule as `store.user`), with the runtime's name kept in the span's `Node` property; the `Owner path` property is the unfolded truth and `Node id` the engine's id. A label also starts at the nearest component the developer wrote — ` › results`, not ` › body › › › … › › results` — because a track entry is only as wide as its span and the panel elides the middle of a label that does not fit, which in a full path is exactly the part that says which component this is. Solid's own flow controls (``, ``, ``, ``…) are not anchors: a node under ` › ` labels ` › › effect`. The same cut applies to fallback spans, flights, findings and the Server track's boundary spans; wherever the label is shorter than the path, the full path is the `Owner path` property. Rich mode carries the why-chain (`formatRerun`) as the tooltip, and causes, deps added/removed, phase, origin and interaction as properties. diff --git a/documentation/solid-2.0/12-ssr-http.md b/documentation/solid-2.0/12-ssr-http.md index 968287a28..93d282f71 100644 --- a/documentation/solid-2.0/12-ssr-http.md +++ b/documentation/solid-2.0/12-ssr-http.md @@ -202,7 +202,7 @@ interface TraceContext { The runtime also hands the trace **down to the browser**, which cannot send a header on the initial document request and has to learn the server's trace from the response: `entries` are emitted as `Server-Timing` metrics (`traceparent;desc="00-…"`) on the response head when it commits — every exit, so frames, server-function responses and redirects carry it too — and, for HTML documents, as `` in the shell head (the `` splice or the `onHead` string; a headless fragment ships only the header). A `Server-Timing` name the application already wrote (an `httpHeader` declaration, an integration's metrics) is respected; the runtime's entries fold in beside it. One rule governs when the browser is told: **only when something is recording the trace** — the incoming `traceparent` had its sampled flag set (the caller says it recorded), or (observe/dev builds) a provider answered. Two things therefore stay silent while remaining fully available to `getTraceContext()`: a trace the runtime originated alone, and an **unsampled** upstream trace (`00-…-00`). Neither has a recorded server span for the browser to attach to, and a parent with flags `00` would make a parent-based browser sampler drop the pageload it would otherwise record. The unsampled case is everyday infrastructure — load balancers and meshes (GCP, Envoy/Istio, Azure Front Door) stamp `traceparent` on every request they forward — so an app on them with no APM sees zero wire change, while forwarding that trace downstream from a server function stays correct W3C propagation. The incoming `baggage` is likewise never echoed to the page — it is upstream context; a provider that wants its own in the document adds it. -The same header carries the request's **timed work** for the Performance panel (RFC 08, [Chrome Performance panel](08-dev-diagnostics.md#chrome-performance-panel-solidjswebperformance-tracks)) under its own gate: `solid-invocation;dur=…;desc=""` on a server-function response, `solid-shell;dur=…` (render start → head commit) and one `solid-boundary;dur=…;desc=""` per boundary that waited and settled before the shell on a document. Dev builds always write them; observe builds only while something has `observe.records.observed("invocation")` / `("boundary")` — the measurement is being taken for a listener, so the header rides along — and prod builds never have the code. The header freezes when the head leaves, so a streamed boundary is not on it; `desc` values are sanitized to printable ASCII (the owner path joins with `>` on the wire) because a header value cannot carry code points above `0xFF`. +The same header carries the request's **timed work** for the Performance panel (RFC 08, [Chrome Performance panel](08-dev-diagnostics.md#chrome-performance-panel-solidjswebperformance-tracks)) each a projection of one of the server's records on `OBSERVE.records`, under that record's gate: `solid-invocation;dur=…;desc=""` on a server-function response (the `"invocation"` record), `solid-shell;dur=…` (the `"render"` record's `shellMs`: render start → the shell complete) and one `solid-boundary;dur=…;desc=""` per boundary that waited and settled before the shell on a document (each a `"boundary"` record). The header is computed from the record objects at head commit — one clock, one object per metric. Dev builds always write them; observe builds only while something has `observe.records.observed("invocation")` / `("render")` / `("boundary")` — the record is being built for a listener, so the header rides along — and prod builds never have the code. The header freezes when the head leaves, so a streamed boundary is not on it; `desc` values are sanitized to printable ASCII (the owner path joins with `>` on the wire) because a header value cannot carry code points above `0xFF`. **The provider** (observe and dev builds — `OBSERVE.server.trace`, see RFC 08) is how an APM overrides or extends the derivation once, globally: Sentry's server SDK answers from OpenTelemetry's active span and adds its `sentry-trace`/`baggage` entries, which the browser SDK reads from the same two carriers. A provider answers every field its vendor decides — `parentId` included, since a vendor continuing from its own header (`sentry-trace`) has a parent the runtime's `traceparent` derivation cannot know. That is the entire integration surface a server-side observer needs from the render — it never owns the head, never re-streams the body, and never has to know the host. diff --git a/packages/signals/src/attribution.prod.ts b/packages/signals/src/attribution.prod.ts index 1f413b403..2a6a85334 100644 --- a/packages/signals/src/attribution.prod.ts +++ b/packages/signals/src/attribution.prod.ts @@ -48,6 +48,7 @@ export type { Acknowledgement, Attribution, AttributionOptions, + AttributionValues, ChangeKind, ChangeOrigin, ChangeRecord, diff --git a/packages/signals/src/attribution.ts b/packages/signals/src/attribution.ts index 139e8acc8..eb80a229e 100644 --- a/packages/signals/src/attribution.ts +++ b/packages/signals/src/attribution.ts @@ -22,6 +22,7 @@ export type { Acknowledgement, Attribution, AttributionOptions, + AttributionValues, ChangeKind, ChangeOrigin, ChangeRecord, diff --git a/packages/signals/src/core/attribution-hooks.ts b/packages/signals/src/core/attribution-hooks.ts index 15e881d49..2191709ed 100644 --- a/packages/signals/src/core/attribution-hooks.ts +++ b/packages/signals/src/core/attribution-hooks.ts @@ -237,7 +237,12 @@ export interface AttributionHooks { export interface InteractionRef { /** Event type — `click`, `keydown`, `input`… */ type: string; - /** The element hit, e.g. `button#next "Next →"`. */ + /** + * The element hit, e.g. `button#next "Next →"` — the tag, then `#id` or + * `[name=…]`, then the element's text in quotes. Describe fully; the + * engine keeps the quoted text as its `values` option allows (the + * record's `target` is its own string, this one is never mutated). + */ target?: string; /** Dispatch time on the `performance.now()` clock; defaults to now. */ at?: number; diff --git a/packages/signals/src/core/attribution.ts b/packages/signals/src/core/attribution.ts index a694c8a34..bb32e857f 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -53,8 +53,9 @@ export type ChangeKind = "write" | "derived" | "async" | "refresh"; * * - `interaction` — a user event handler (the web runtime marks dispatch via * `withInteraction`). `name` is the event type, `target` the element hit - * (`button#next "Next →"`), `at` the dispatch time on the `performance.now()` - * clock — the base every feedback-latency number is measured from. + * (`button#next "Next →"` — the quoted text as `AttributionOptions.values` + * allows), `at` the dispatch time on the `performance.now()` clock — the + * base every feedback-latency number is measured from. * - `effect` — an effect callback (`name` = the effect's name; `run` = the * compute run whose effect phase performed the write, when that run was * recorded — so a write can be joined to the re-run that produced it). @@ -109,7 +110,10 @@ export interface ChangeRecord { * for a record built elsewhere (a `HeldWrite`, a deserialized artifact). */ nodeId?: number; - /** Short previews of the value transition (writes only). */ + /** + * Short previews of the value transition (writes only) — present under + * `AttributionOptions.values: "full"`, never carried otherwise. + */ prev?: string; value?: string; /** First user frames of the triggering write's stack (opt-in). */ @@ -354,9 +358,57 @@ export interface GraphSize { edges: number; } +/** + * What user data the engine's records carry — see `AttributionOptions.values`. + * Ordered: `"full"` carries the most, `"none"` the least. + */ +export type AttributionValues = "full" | "labels" | "none"; + export interface AttributionOptions { /** Pretty-print each re-run to the console (default true). */ log?: boolean; + /** + * What user data records carry. Default per build tier: `"full"` in dev + * builds, `"none"` in observe builds — a production observability + * artifact carries no user data unless a holder asks for it. Records name things — + * owner paths, `name` options, store paths, route patterns — and are + * otherwise numbers, kinds and outcomes; this option governs the fields + * that quote application data: the value previews on a root write + * (`ChangeRecord.prev`/`value`, and the `HeldWrite.prev`/`value` copied + * from them), the text of the element an interaction hit + * (`ChangeOrigin.target` / `InteractionEvent.target` — the `"Next →"` in + * `button#next "Next →"`), and every sentence built from those (the + * console log, `formatRerun`, `formatOrigin`, the SILENT_HOLD / LONG_HOLD + * / STACKED_HOLDS verdicts naming the interaction, OPTIMISTIC_REVERTED's + * shown and settled values). Applied where the record is BUILT, so + * nothing downstream — a ring buffer, a fold, a listener, an exporter — + * ever holds what the level excludes. + * + * - `"full"` — previews of the written values (strings quoted and cut at + * 40 characters, numbers and booleans verbatim, everything else a type + * tag such as `Array(12)`) and the element's text: what makes dev + * output readable. + * - `"labels"` — no value previews; element text kept only on a `button` + * or an `a` (the control's caption — what the person pressed is the + * point of an interaction record) and dropped for anything else (the + * text of a `div` or a `td` is content). + * - `"none"` — no value previews, no element text: names, numbers, kinds + * and outcomes only. What an observe-tier holder that carries records + * out of the process (an APM adapter) should pass. + * + * Across holds the LEAST permissive level wins — the one key that + * combines the other way from the rest, where a hold can only ask for + * more: a holder that must not see user data is not overruled by one + * that wants it. A holder that names no level asks for the tier's + * default, so in an observe build it tightens to `"none"` beside anyone; + * a single holder passing `"full"` there gets `"full"`, and an explicit + * `"full"` never loosens what another holder demanded. Governs what is + * built from the moment the level is in effect; records already in a + * ring buffer keep what they carried. A + * navigation's concrete paths and params (`ChangeOrigin.to`/`from`/ + * `params`) are the router's description and are not governed here. + */ + values?: AttributionValues; /** * Run the cost checks — the thresholded findings over the engine's own * accounting: `hotRuns`, `hotTime`, `wideDeps`, `unstableMemos`, @@ -570,6 +622,11 @@ let changeSeq = 0; let runSeq = 0; const defaultOptions = { log: true, + // Tier-dependent (see `AttributionOptions.values`): dev output is for the + // developer at the console and shows everything; an observe build is a + // production artifact and carries no user data unless a holder asks. The + // literal folds per build — the observe engine ships `"none"` only. + values: (__DEV__ ? "full" : "none") as AttributionValues, checks: true, stacks: false, historyLimit: 200, @@ -706,6 +763,12 @@ function wantsRerun(): boolean { return options.log || folds.length > 0 || records.observed("rerun"); } +/** + * A short preview of a written value for a record — only ever called under + * `values: "full"` (`stampWrite`, OPTIMISTIC_REVERTED): the other levels + * carry no previews, and the check is made at the call site so the value is + * not even looked at. + */ function preview(v: unknown): string { if (v === null) return "null"; switch (typeof v) { @@ -765,13 +828,35 @@ const actionInteractions = new WeakMap(); let currentInteraction: ChangeOrigin | null = null; const interactionStack: (ChangeOrigin | null)[] = []; +/** + * The element label an interaction record carries, from the one the runtime + * described (`tag`, then `#id` or `[name=…]`, then the element's text in + * quotes — `button#next "Next →"`), cut to what `options.values` allows: the + * text is the part after the first ` "`; under `"labels"` it stays on a + * `button` or an `a`, under `"none"` never. The ref's own string is never + * mutated — the runtime's object is the runtime's. + */ +function targetLabel(target: string): string { + const level = options.values; + if (level === "full") return target; + const text = target.indexOf(' "'); + if (text === -1) return target; + if (level === "labels") { + const head = target.slice(0, text); + const mark = head.search(/[#[]/); + const tag = mark === -1 ? head : head.slice(0, mark); + if (tag === "button" || tag === "a") return target; + } + return target.slice(0, text); +} + function interactionStart(ref: InteractionRef): void { interactionStack.push(currentInteraction); // One clock read: the frame opens now; the interaction began at `ref.at` // when the runtime dated it (the event's own timestamp), else now too. const opened = now(); const origin: ChangeOrigin = { kind: "interaction", name: ref.type, at: ref.at ?? opened }; - if (ref.target) origin.target = ref.target; + if (ref.target) origin.target = targetLabel(ref.target); currentInteraction = origin; openInteraction(origin, opened); } @@ -1031,7 +1116,10 @@ function stampWrite( name: nodeName(node), nodeId: devId(node) }; - if (value !== NO_VALUES) { + // The previews are the record's one look at the written values: under + // any level but `"full"` they are not taken, so the record never carries + // them (nor does the `HeldWrite` copied from it, nor a sentence built on it). + if (value !== NO_VALUES && options.values === "full") { record.prev = prev === NO_VALUES ? undefined : preview(prev); record.value = preview(value); } @@ -1525,7 +1613,10 @@ export interface Attribution { * a hold withdraws its requests — a track enabled with `log: false` beside * a console session never silences it, and a capture with tight * thresholds beside a records-only adapter runs the checks for its own - * duration. The release is idempotent; the last release uninstalls the + * duration. One key runs the other way: `values` combines to the LEAST + * permissive level any holder asked for, so an adapter that must not see + * user data (`values: "none"`) is honoured beside a console session that + * wants everything. The release is idempotent; the last release uninstalls the * hooks and clears every ring buffer. A consumer that re-`enable()`s to * reopen its window must release both holds (or `disable()`). * @@ -2938,13 +3029,24 @@ function checkOptimisticRevert( const equals = (el as { _equals?: false | ((a: unknown, b: unknown) => boolean) })._equals; if (equals && equals(shown, truth)) return; const source = nodeName(el); + // The two values are user data: quoted only under `values: "full"`; the + // other levels say what happened without saying what was shown. + const values = options.values === "full"; + const change = how === "superseded" ? "settled to" : "reverted to"; const message = - `[OPTIMISTIC_REVERTED] the optimistic value of ${source} showed ${preview(shown)}; it ` + - `${how === "superseded" ? "settled to" : "reverted to"} ${preview(truth)}. The person saw ` + - `the guess, then the correction. A revert on failure is the feature; one that recurs says ` + - `the guess is wrong for this input or the action fails often — show the failure where the ` + - `value renders (the action's catch, an Errored boundary) rather than letting the value ` + - `snap back on its own.`; + `[OPTIMISTIC_REVERTED] the optimistic value of ${source} ` + + (values + ? `showed ${preview(shown)}; it ${change} ${preview(truth)}.` + : `${how === "superseded" ? "was superseded by the settled value" : "reverted at settle"}.`) + + ` The person saw the guess, then the correction. A revert on failure is the feature; one ` + + `that recurs says the guess is wrong for this input or the action fails often — show the ` + + `failure where the value renders (the action's catch, an Errored boundary) rather than ` + + `letting the value snap back on its own.`; + const data: Record = { source, how }; + if (values) { + data.shown = preview(shown); + data.truth = preview(truth); + } emitDiagnostic( { code: "OPTIMISTIC_REVERTED", @@ -2952,7 +3054,7 @@ function checkOptimisticRevert( severity: "info", message, nodeName: source, - data: { source, shown: preview(shown), truth: preview(truth), how } + data }, el ); @@ -4249,13 +4351,20 @@ function resolveHold(opts: AttributionOptions | undefined): Options { return out; } +/** `values` levels by how much they carry: the merge takes the lowest. */ +const VALUES_RANK: Record = { none: 0, labels: 1, full: 2 }; + /** * The more demanding of two settings for one key: `true` over `false`, the * larger `historyLimit`, a threshold config over `false`, and between two * configs the field values that fire sooner — the lower count, budget or - * millisecond bound, the longer `windowMs`. + * millisecond bound, the longer `windowMs`. For `values` "more demanding" + * is LESS data: the level that carries the least wins, so a holder that + * must not see user data is never overruled by one that wants it. */ function demanding(key: keyof Options, a: unknown, b: unknown): unknown { + if (key === "values") + return VALUES_RANK[a as AttributionValues] <= VALUES_RANK[b as AttributionValues] ? a : b; if (typeof a === "boolean") return a || b; if (key === "historyLimit") return Math.max(a as number, b as number); if (a === false) return b; @@ -4278,7 +4387,8 @@ function demanding(key: keyof Options, a: unknown, b: unknown): unknown { * the result does not depend on the order the holds were taken: the console * log prints while any holder wants it, a check runs while any holder wants * it and at the most sensitive threshold anyone asked for, the ring buffer - * is the largest requested. With no hold outstanding the defaults stand. + * is the largest requested, and records carry the least user data any + * holder allows (`values`). With no hold outstanding the defaults stand. */ function applyOptions(): void { if (holds.length === 0) { diff --git a/packages/signals/tests/attribution-values.test.ts b/packages/signals/tests/attribution-values.test.ts new file mode 100644 index 000000000..1d5604b60 --- /dev/null +++ b/packages/signals/tests/attribution-values.test.ts @@ -0,0 +1,348 @@ +/** + * `AttributionOptions.values` — what user data the engine's records carry. + * + * Claim under test: the level is applied AT THE SOURCE, when the record is + * built, so nothing downstream (a formatter, an adapter, an exporter) has + * to scrub. `"full"` (the default) keeps value previews on `ChangeRecord`/ + * `HeldWrite` and the element text on `ChangeOrigin.target`; `"labels"` + * drops the previews and keeps the text only on a `button` or an `a`; + * `"none"` drops both. Findings the engine phrases from those fields say + * as much as the level allows and no more. Across holds the LEAST + * permissive level wins, and a release restores the others'. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { attribution, formatOrigin, formatRerun } from "../src/attribution.js"; +import { + action, + createEffect, + createMemo, + createOptimistic, + createRenderEffect, + createRoot, + createSignal, + flush, + OBSERVE +} from "../src/index.js"; +import type { + AttributionOptions, + AttributionValues, + HoldEvent, + InteractionEvent, + RerunEvent +} from "../src/core/attribution.js"; +import type { DiagnosticEvent } from "../src/core/dev.js"; +import type { RecordListener, RecordType } from "../src/core/dev.js"; + +const offs: (() => void)[] = []; +function on(type: K, listener: RecordListener): void { + offs.push(OBSERVE!.records.subscribe(type, listener)); +} + +afterEach(() => { + for (const off of offs.splice(0)) off(); + attribution.disable(); + flush(); + vi.restoreAllMocks(); +}); + +const QUIET: AttributionOptions = { log: false, hotRuns: false, hotTime: false, waterfalls: false }; + +function arm(opts: AttributionOptions = {}) { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "info").mockImplementation(() => {}); + const release = attribution.enable({ ...QUIET, ...opts }); + const reruns: RerunEvent[] = []; + const interactions: InteractionEvent[] = []; + const holds: HoldEvent[] = []; + const findings: DiagnosticEvent[] = []; + on("rerun", e => reruns.push(e)); + on("interaction", e => interactions.push(e)); + on("hold", e => holds.push(e)); + OBSERVE!.diagnostics.subscribe(e => findings.push(e)); + return { release, reruns, interactions, holds, findings }; +} + +const wait = (ms: number) => new Promise(r => setTimeout(r, ms)); +async function until(cond: () => boolean, what: string, timeout = 5000) { + const start = Date.now(); + for (;;) { + flush(); + if (cond()) return; + if (Date.now() - start > timeout) throw new Error(`timed out waiting for ${what}`); + await wait(5); + } +} + +const CARD = { type: "click", target: 'div#card "Personal note about a person"' }; +const SAVE = { type: "click", target: 'button#save "Save"' }; +const LINK = { type: "click", target: 'a[name=next] "Next page"' }; +const FIELD = { type: "input", target: "input#email" }; + +/** A signal with an effect reading it: one re-run per write, one cause each. */ +function counter() { + const [n, setN] = createSignal(0, { name: "n" }); + createRoot(() => createEffect(n, () => {}, { name: "reader" })); + flush(); + return { n, setN }; +} + +/** Three interactions, one write each. */ +function click(setN: (v: number) => void) { + OBSERVE!.attribution.withInteraction(CARD, () => setN(1)); + flush(); + OBSERVE!.attribution.withInteraction(SAVE, () => setN(2)); + flush(); + OBSERVE!.attribution.withInteraction(LINK, () => setN(3)); + flush(); + OBSERVE!.attribution.withInteraction(FIELD, () => setN(4)); + flush(); +} + +describe("AttributionOptions.values", () => { + describe('"full" (the dev default)', () => { + // The suite runs the source under `__DEV__: true`; the observe build's + // default (`"none"`) is pinned against the artifact in dist-artifacts.test.ts. + it("is the dev default: previews and element text, as dev output has always read", () => { + const { reruns, interactions } = arm(); + const { setN } = counter(); + click(setN); + + expect(reruns.map(r => r.causes[0])).toMatchObject([ + { name: "n", prev: "0", value: "1" }, + { name: "n", prev: "1", value: "2" }, + { name: "n", prev: "2", value: "3" }, + { name: "n", prev: "3", value: "4" } + ]); + expect(interactions.map(i => i.target)).toEqual([ + CARD.target, + SAVE.target, + LINK.target, + FIELD.target + ]); + expect(reruns.map(r => r.causes[0].origin!.target)).toEqual(interactions.map(i => i.target)); + expect(formatOrigin(interactions[0].origin)).toBe( + 'click on div#card "Personal note about a person"' + ); + expect(formatRerun(reruns[0])).toContain("0 → 1"); + }); + }); + + describe('"labels"', () => { + it("drops value previews from every change record", () => { + const { reruns } = arm({ values: "labels" }); + const { setN } = counter(); + click(setN); + for (const run of reruns) { + const cause = run.causes[0]; + expect(cause.name).toBe("n"); + expect(cause.prev).toBeUndefined(); + expect(cause.value).toBeUndefined(); + } + expect(formatRerun(reruns[0])).not.toContain("→"); + expect(formatRerun(reruns[0])).toContain('signal "n" write'); + }); + + it("keeps element text on a button or a link only", () => { + const { interactions, reruns } = arm({ values: "labels" }); + const { setN } = counter(); + click(setN); + expect(interactions.map(i => i.target)).toEqual([ + "div#card", + 'button#save "Save"', + 'a[name=next] "Next page"', + "input#email" + ]); + // The same origin object rides every record the interaction caused. + expect(reruns.map(r => r.causes[0].origin!.target)).toEqual(interactions.map(i => i.target)); + expect(formatOrigin(interactions[0].origin)).toBe("click on div#card"); + expect(formatOrigin(interactions[1].origin)).toBe('click on button#save "Save"'); + }); + }); + + describe('"none"', () => { + it("drops previews and every element's text, buttons and links included", () => { + const { reruns, interactions } = arm({ values: "none" }); + const { setN } = counter(); + click(setN); + for (const run of reruns) { + expect(run.causes[0].prev).toBeUndefined(); + expect(run.causes[0].value).toBeUndefined(); + } + expect(interactions.map(i => i.target)).toEqual([ + "div#card", + "button#save", + "a[name=next]", + "input#email" + ]); + expect(JSON.stringify([reruns, interactions])).not.toMatch(/Personal note|Save|Next page/); + }); + + it("never mutates the runtime's InteractionRef", () => { + arm({ values: "none" }); + const { setN } = counter(); + const ref = { ...CARD }; + OBSERVE!.attribution.withInteraction(ref, () => setN(1)); + flush(); + expect(ref).toEqual(CARD); + }); + }); + + describe("held writes and the sentences built from them", () => { + /** A write held behind an async memo, SILENT (no acknowledgement): one HoldEvent + one SILENT_HOLD. */ + async function silentHold(target: string) { + const [page, setPage] = createSignal(1, { name: "page" }); + let resolve: ((v: string) => void) | null = null; + const posts = createMemo( + () => { + const p = page(); + return new Promise(r => (resolve = v => r(`${v}-p${p}`))); + }, + { name: "posts" } + ); + const shown: string[] = []; + createRoot(() => + createRenderEffect( + posts, + v => { + shown.push(String(v)); + }, + { name: "feed" } + ) + ); + flush(); + resolve!("a"); + await until(() => shown.includes("a-p1"), "initial load"); + OBSERVE!.attribution.withInteraction({ type: "click", target }, () => setPage(2)); + flush(); + await wait(5); + resolve!("b"); + await until(() => shown.includes("b-p2"), "the held page to land"); + } + + it('"full": HeldWrite carries the previews; SILENT_HOLD quotes them and the element text', async () => { + const { holds, findings } = arm({ holds: { infoMs: 0, warnMs: 0 } }); + await silentHold('div#card "Personal note"'); + expect(holds).toHaveLength(1); + expect(holds[0].heldWrites).toEqual([ + expect.objectContaining({ name: "page", prev: "1", value: "2" }) + ]); + const silent = findings.find(f => f.code === "SILENT_HOLD")!; + expect(silent.message).toContain( + 'click on div#card "Personal note" wrote "page" (1 → 2); the write was held' + ); + expect(silent.data).toMatchObject({ + interaction: { type: "click", target: 'div#card "Personal note"' } + }); + }); + + it('"none": HeldWrite has no previews; SILENT_HOLD names the write and the element, nothing more', async () => { + const { holds, findings } = arm({ values: "none", holds: { infoMs: 0, warnMs: 0 } }); + await silentHold('div#card "Personal note"'); + expect(holds).toHaveLength(1); + expect(holds[0].heldWrites).toHaveLength(1); + expect(holds[0].heldWrites[0].name).toBe("page"); + expect(holds[0].heldWrites[0].prev).toBeUndefined(); + expect(holds[0].heldWrites[0].value).toBeUndefined(); + expect(holds[0].interaction!.target).toBe("div#card"); + expect(holds[0].heldWrites[0].origin!.target).toBe("div#card"); + const silent = findings.find(f => f.code === "SILENT_HOLD")!; + expect(silent.message).toContain('click on div#card wrote "page"; the write was held'); + expect(silent.data).toMatchObject({ interaction: { type: "click", target: "div#card" } }); + // Nothing on the finding — sentence or data — carries what the level excluded. + expect(JSON.stringify(silent)).not.toMatch(/Personal note|1 → 2/); + }); + + it('OPTIMISTIC_REVERTED quotes the two values under "full" only', async () => { + const revert = async (values: AttributionValues) => { + const { findings, release } = arm({ values }); + let resolve!: () => void; + const gate = new Promise(r => (resolve = r)); + const [status, setStatus] = createOptimistic("idle", { name: "status" }); + createRoot(() => createRenderEffect(status, () => {}, { name: "badge" })); + flush(); + const save = action(function* save() { + setStatus("saved"); + yield gate; + }); + const p = save(); + flush(); + resolve(); + await p; + flush(); + release(); + return findings.find(f => f.code === "OPTIMISTIC_REVERTED")!; + }; + + const full = await revert("full"); + expect(full.message).toContain('showed "saved"; it reverted to "idle"'); + expect(full.data).toMatchObject({ + source: "status", + how: "reverted", + shown: '"saved"', + truth: '"idle"' + }); + + const none = await revert("none"); + expect(none.message).toContain("the optimistic value of status reverted at settle."); + expect(none.message).not.toMatch(/saved|idle/); + expect(none.data).toEqual({ source: "status", how: "reverted" }); + }); + }); + + describe("across holds", () => { + it("the least permissive level wins, and a release restores the others'", () => { + const { interactions, reruns } = arm({ values: "full" }); + const { setN } = counter(); + const releaseNone = attribution.enable({ ...QUIET, values: "none" }); + OBSERVE!.attribution.withInteraction(SAVE, () => setN(1)); + flush(); + expect(interactions.at(-1)!.target).toBe("button#save"); + expect(reruns.at(-1)!.causes[0].prev).toBeUndefined(); + + const releaseLabels = attribution.enable({ ...QUIET, values: "labels" }); + OBSERVE!.attribution.withInteraction(SAVE, () => setN(2)); + flush(); + // Three holders — full, none, labels — resolve to none. + expect(interactions.at(-1)!.target).toBe("button#save"); + + releaseNone(); + OBSERVE!.attribution.withInteraction(CARD, () => setN(3)); + flush(); + // full + labels → labels: no text on the div, no previews. + expect(interactions.at(-1)!.target).toBe("div#card"); + expect(reruns.at(-1)!.causes[0].prev).toBeUndefined(); + OBSERVE!.attribution.withInteraction(SAVE, () => setN(4)); + flush(); + expect(interactions.at(-1)!.target).toBe('button#save "Save"'); + + releaseLabels(); + OBSERVE!.attribution.withInteraction(CARD, () => setN(5)); + flush(); + // The first holder's full is what remains. + expect(interactions.at(-1)!.target).toBe(CARD.target); + expect(reruns.at(-1)!.causes[0]).toMatchObject({ prev: "4", value: "5" }); + }); + + it("a holder that does not name a level asks for the tier's default (dev: full) — it never loosens another's", () => { + const { interactions } = arm({ values: "none" }); + const { setN } = counter(); + const release = attribution.enable(QUIET); + OBSERVE!.attribution.withInteraction(SAVE, () => setN(1)); + flush(); + expect(interactions.at(-1)!.target).toBe("button#save"); + release(); + }); + + it("applies from the moment the level is in effect: earlier records keep what they carried", () => { + const { interactions } = arm(); + const { setN } = counter(); + OBSERVE!.attribution.withInteraction(CARD, () => setN(1)); + flush(); + const release = attribution.enable({ ...QUIET, values: "none" }); + OBSERVE!.attribution.withInteraction(CARD, () => setN(2)); + flush(); + expect(interactions.map(i => i.target)).toEqual([CARD.target, "div#card"]); + release(); + }); + }); +}); diff --git a/packages/signals/tests/dist-artifacts.test.ts b/packages/signals/tests/dist-artifacts.test.ts index 9792b886b..efe0150d8 100644 --- a/packages/signals/tests/dist-artifacts.test.ts +++ b/packages/signals/tests/dist-artifacts.test.ts @@ -457,6 +457,57 @@ describe("@solidjs/signals engine per tier", () => { ); }); + // `AttributionOptions.values` defaults per tier — the literal is folded at + // build time, so only the artifacts can show it: dev shows everything, an + // observe build carries no user data unless a holder asks. Probed through + // the interaction target (the one field every level governs). + async function defaultTarget(core: any, engine: Engine, opts?: Record) { + const { attribution } = engine; + const observe = core.OBSERVE; + const seen: string[] = []; + const off = observe.records.subscribe("interaction", (e: any) => seen.push(e.target)); + const release = attribution.enable({ log: false, hotRuns: false, hotTime: false, ...opts }); + try { + const setCount = core.createRoot(() => { + const [count, set] = core.createSignal(0, { name: "count" }); + core.createEffect(count, () => {}, { name: "reader" }); + return set; + }); + core.flush(); + observe.attribution.withInteraction( + { type: "click", target: 'div#card "Personal note"' }, + () => setCount(1) + ); + core.flush(); + } finally { + off(); + release(); + attribution.disable(); + } + return seen; + } + + test("values: the dev engine defaults to full", async () => { + const seen = await defaultTarget( + await import("../dist/dev.js"), + (await import("../dist/dev.attribution.js")) as Engine + ); + expect(seen).toEqual(['div#card "Personal note"']); + }); + + test("values: the observe engine defaults to none; an explicit full applies when alone", async () => { + const core = await import("../dist/observe/index.js"); + const engine = (await import("../dist/observe/attribution.js")) as Engine; + expect(await defaultTarget(core, engine)).toEqual(["div#card"]); + expect(await defaultTarget(core, engine, { values: "full" })).toEqual([ + 'div#card "Personal note"' + ]); + // The dev branch of the default folded out: the artifact's default is + // the literal `"none"`, not a runtime choice. + const src = readFileSync(new URL("../dist/observe/attribution.js", import.meta.url), "utf8"); + expect(src).not.toMatch(/\?\s*"full"\s*:\s*"none"/); + }); + test("the observe tree keeps the engine out of the core's module graph", () => { // Nothing reachable from index.js may import core/attribution.js: the // per-module tree is what lets a bundler drop the engine, and one stray diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index 98425bc4f..0f184f6bf 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -101,13 +101,15 @@ function ssrLoadingBoundary( let passes = 0; // Observe tier: the boundary RECORD (`OBSERVE.records`, type `"boundary"` - // — see `BoundaryEvent`), for a boundary that waited. Cost is paid only - // with a listener (or in dev, where the checks below read the same - // facts): one `performance.now()` at discovery, one at settle. Delivered - // at settle; when a `` group coordinates the fragment swap the - // record waits for the group's `onReveal` so it can carry `heldMs` — the - // time finished content sat behind its siblings. (A group that never - // reveals — the stream abandoned — loses the record; + // — see `BoundaryEvent`), for a boundary that waited. One gate for the + // record and the response's `solid-boundary` metric projected from it: + // dev builds always (the checks below read the same facts), observe + // builds with a listener. Cost: one `performance.now()` at discovery, + // one at settle, the record object when either reader wants it. + // Delivered at settle; when a `` group coordinates the fragment + // swap the record waits for the group's `onReveal` so it can carry + // `heldMs` — the time finished content sat behind its siblings. (A group + // that never reveals — the stream abandoned — loses the record; // `SSR_STREAM_ABANDONED` is that request's account.) const observed = IS_OBSERVE ? OBSERVE!.records.observed("boundary") : false; const timed = IS_DEV || observed; @@ -120,28 +122,19 @@ function ssrLoadingBoundary( recorded = true; const settledAt = timed ? performance.now() : 0; if (IS_DEV) checkWaited(outcome, settledAt - discoveredAt); + if (!timed) return; // The document's `Server-Timing` (the web runtime's seam on the render - // context — see `_timing`): a boundary the shell WAITED on — a pass past - // discovery, settled before the flush — labelled by its owner path, the - // label the client's `fallback` record and the findings carry. One that - // streams settled after the head left and cannot ride the header; one - // decided on its first pass (a renderToString fallback, a client hole) - // held nothing up. - const timing = timed && !streamed && passes > 1 ? ctx._timing : undefined; + // context — see `_recordBoundary`) takes the record of a boundary the + // shell WAITED on — a pass past discovery, settled before the flush. + // One that streams settled after the head left and cannot ride the + // header; one decided on its first pass (a renderToString fallback, a + // client hole) held nothing up. + const toHeader = !streamed && passes > 1 ? ctx._recordBoundary : undefined; + if (!observed && toHeader === undefined) return; // The core's walk (`_parent` + `_name`), the same one its diagnostics // make over these owners, so the record, the finding it may pair with // and the metric locate to the same ` › `. - const path = observed || timing !== undefined ? OBSERVE!.ownerPath(o) : undefined; - if (timing !== undefined) { - // ASCII on the wire (a header value is a byte string); the adapter - // renders the path with the artifact's ` › `. - timing.push({ - name: "solid-boundary", - dur: settledAt - discoveredAt, - desc: path ? path.join(" > ") : id - }); - } - if (!observed) return; + const path = OBSERVE!.ownerPath(o); const event: BoundaryEvent = { id, at: discoveredAt, @@ -153,6 +146,8 @@ function ssrLoadingBoundary( }; if (revealGroup) event.revealGroup = revealGroup.id; if (path) event.ownerPath = path; + if (toHeader !== undefined) toHeader(event); + if (!observed) return; const live: BoundaryLive = {}; if (outcome === "error") live.error = error; // Only a fragment swap can be held: `done` exists once the fragment is diff --git a/packages/solid/src/server/shared.ts b/packages/solid/src/server/shared.ts index 1a7656a19..4c8dbc975 100644 --- a/packages/solid/src/server/shared.ts +++ b/packages/solid/src/server/shared.ts @@ -1,5 +1,6 @@ import { getOwner, getNextChildId, getContext, devPeekNextChildId } from "./signals.js"; import type { Context } from "./signals.js"; +import type { BoundaryEvent } from "./observe.js"; export type SSRTemplateObject = | { t: string[]; h: Function[]; p: Promise[] } @@ -66,14 +67,15 @@ export type HydrationContext = { /** @internal Tracks which Loading boundary is currently rendering. Set by @solidjs/web via applyAssetTracking(). */ _currentBoundaryId?: string | null; /** - * @internal The document's timed server work for its response's - * `Server-Timing`, in completion order. Set by @solidjs/web at render - * start while it times the document (dev; observe with a `"boundary"` - * listener); `ssrLoadingBoundary` pushes a `solid-boundary` metric for - * each boundary that waited and settled before the shell. Absent when - * the document is not timed — the boundary then pushes nothing. + * @internal The seam a `` boundary files its `"boundary"` record + * through for the response's `Server-Timing` (`solid-boundary` is a + * projection of the record). Set by @solidjs/web at render start in + * observe builds; `ssrLoadingBoundary` calls it with the record of each + * boundary that waited and settled before the shell — the ones whose wait + * the head can still account for — under its own gate (dev, or a + * `"boundary"` listener). Absent outside observe builds. */ - _timing?: { name: string; dur: number; desc?: string }[]; + _recordBoundary?: (event: BoundaryEvent) => void; /** * @internal Containment channel for errors surfacing in async resume loops * (boundary retries, flush passes), where nothing is on the stack to catch diff --git a/packages/web/performance-tracks/src/index.ts b/packages/web/performance-tracks/src/index.ts index 5114d4c20..713437142 100644 --- a/packages/web/performance-tracks/src/index.ts +++ b/packages/web/performance-tracks/src/index.ts @@ -88,13 +88,6 @@ export interface PerformanceTracksOptions { * Each path falls back to the other where its API is missing. */ rich?: boolean; - /** - * Drop what a shared trace should not carry: value previews on causes - * and held writes, and target text on anything but a `button` or `a` - * (the production-observability posture). Default: observe builds - * (`!IS_DEV`); dev shows everything. - */ - scrub?: boolean; } /** The Performance panel's palette for extension entries. */ @@ -226,8 +219,7 @@ export function enablePerformanceTracks(options: PerformanceTracksOptions = {}): if (emitter === undefined) return noop; const minMs = options.minMs ?? (IS_DEV ? 0 : 0.05); - const scrub = options.scrub ?? !IS_DEV; - const painter = new Painter(emitter, minMs, scrub); + const painter = new Painter(emitter, minMs); const server = new ServerSpans(emitter); server.start(); @@ -429,8 +421,7 @@ class Painter { private readonly names = new Map(); constructor( private readonly emit: Emitter, - private readonly minMs: number, - private readonly scrub: boolean + private readonly minMs: number ) { this.rich = emit.rich; } @@ -465,7 +456,7 @@ class Painter { let tooltip: string | undefined; let properties: Properties | undefined; if (this.rich) { - tooltip = this.scrub ? formatRerun(scrubRerun(event)) : formatRerun(event); + tooltip = formatRerun(event); properties = [ ["Run", `${event.run} (run ${event.nodeRuns} of this node)`], ["Self time", ms(event.selfMs)], @@ -479,7 +470,7 @@ class Painter { if (event.depsRemoved.length > 0) properties.push(["Deps removed", event.depsRemoved.join(", ")]); if (event.causes.length > 0) - properties.push(["Causes", event.causes.map(c => rootCause(c, this.scrub)).join("; ")]); + properties.push(["Causes", event.causes.map(rootCause).join("; ")]); const origin = rootOrigin(event.causes); if (origin !== undefined && origin !== event.interaction) properties.push(["Origin", this.origin(origin)]); @@ -573,9 +564,10 @@ class Painter { * full path in `Owner path`), coloured by severity. A `warn`-or-worse finding is also annotated as a * performance issue — the panel's Insights sidebar lists those — with the * repair guide's section for the code as its link. `info` stays a plain - * marker. The message and `data` are the engine's own; under the scrub - * only the code, kind and owner are carried (a responsiveness finding's - * sentence names the element the user hit). + * marker. The message and `data` are the emitter's own, carried as + * delivered: what user data a responsiveness finding's sentence quotes + * (the element the user hit) is the engine's `values` option, decided + * where the sentence is built. */ diagnostic(event: DiagnosticEvent, subject: DiagnosticSubject | undefined): void { const owner = event.ownerPath?.join(" › "); @@ -593,7 +585,7 @@ class Painter { let properties: Properties | undefined; let issue: PerformanceIssue | undefined; if (this.rich) { - tooltip = this.scrub ? `${event.kind} finding ${event.code}` : event.message; + tooltip = event.message; properties = [ ["Code", event.code], ["Kind", event.kind], @@ -601,17 +593,15 @@ class Painter { ]; if (owner !== undefined) properties.push(["Owner path", owner]); if (event.nodeName !== undefined) properties.push(["Node", event.nodeName]); - if (!this.scrub) { - properties.push(["Message", event.message]); - if (event.data !== undefined) { - // The primitive fields ride along (`holdMs`, `relay`, `soleWriter`); - // structured ones (`interaction`, `navigation`) are the message's. - for (const [key, value] of Object.entries(event.data)) { - if (typeof value === "number") - properties.push([key, Number.isInteger(value) ? String(value) : value.toFixed(2)]); - else if (typeof value === "string" || typeof value === "boolean") - properties.push([key, String(value)]); - } + properties.push(["Message", event.message]); + if (event.data !== undefined) { + // The primitive fields ride along (`holdMs`, `relay`, `soleWriter`); + // structured ones (`interaction`, `navigation`) are the message's. + for (const [key, value] of Object.entries(event.data)) { + if (typeof value === "number") + properties.push([key, Number.isInteger(value) ? String(value) : value.toFixed(2)]); + else if (typeof value === "string" || typeof value === "boolean") + properties.push([key, String(value)]); } } // The repair guide is dev guidance (`DEV.guideUrl`); an observe build @@ -831,7 +821,7 @@ class Painter { ["Held", ms(event.holdMs)], ["Tail", `${ms(event.tailMs)} (last write → commit)`], ["Flushes", String(event.flushes)], - ["Held writes", event.heldWrites.map(w => heldWrite(w, this.scrub)).join(", ") || "none"], + ["Held writes", event.heldWrites.map(heldWrite).join(", ") || "none"], [ "Acknowledged by", event.acknowledgements.map(a => `${a.kind}(${a.source})`).join(", ") || "nothing" @@ -984,8 +974,8 @@ class Painter { } /** - * Root writes as one line, `count 0 → 1, name "a" → "b"`, values dropped - * under the scrub; past `limit` writes, `+N more`. + * Root writes as one line, `count 0 → 1, name "a" → "b"` (the previews as + * the engine's `values` level carries them); past `limit` writes, `+N more`. */ private writes(roots: ChangeRecord[], limit: number): string { const shown = roots.slice(0, limit).map(r => { @@ -995,16 +985,16 @@ class Painter { : r.kind === "refresh" ? `refresh ${r.name}` : r.name; - if (!this.scrub && r.prev !== undefined) out += ` ${r.prev} → ${r.value}`; + if (r.prev !== undefined) out += ` ${r.prev} → ${r.value}`; return out; }); if (roots.length > limit) shown.push(`+${roots.length - limit} more`); return shown.join(", "); } - /** `formatOrigin`, through the scrub when the trace may be shared. */ + /** `formatOrigin` — the shared formatter, so the tracks and the artifact read alike. */ private origin(origin: ChangeOrigin): string { - return formatOrigin(this.scrub ? scrubOrigin(origin) : origin); + return formatOrigin(origin); } } @@ -1174,10 +1164,11 @@ function bySelfTime(selfMs: number): TrackColor { // --- Server spans -------------------------------------------------------------- // // What the server did inside a client span. The server runtime puts its -// timed work on the response's `Server-Timing` header (trace.ts -// `TimingMetric`: `solid-invocation` on a server-function response — -// `solid-shell` and the `solid-boundary`s that settled inside it on the -// document), and this paints those durations on the `Server` track under +// timed work on the response's `Server-Timing` header (projected from its +// records — trace.ts `appendTraceServerTiming`: `solid-invocation` on a +// server-function response — `solid-shell` and the `solid-boundary`s that +// settled inside it on the document), and this paints those durations on +// the `Server` track under // the client span they belong to, so the wire is the visible gap between // the two. Placement comes from the browser's own resource timing: the // head left the server right after the function returned, so a server @@ -1463,54 +1454,22 @@ function ms(value: number): string { return `${value.toFixed(2)}ms`; } -/** The root of a cause chain, as one line: `signal "count" write 0 → 1 — click on button#next`. */ -function rootCause(cause: ChangeRecord, scrub: boolean): string { +/** + * The root of a cause chain, as one line: `signal "count" write 0 → 1 — click + * on button#next`. What user data it quotes — the previews, the element's + * text — is what the engine put on the record (`AttributionOptions.values`), + * so a shared trace carries only what the engine's holders allowed. + */ +function rootCause(cause: ChangeRecord): string { let root = cause; while (root.causes !== undefined && root.causes.length > 0) root = root.causes[0]; let out = `${root.kind === "derived" ? "memo" : "signal"} "${root.name}" ${root.kind}`; - if (!scrub && root.prev !== undefined) out += ` ${root.prev} → ${root.value}`; + if (root.prev !== undefined) out += ` ${root.prev} → ${root.value}`; if (root.origin !== undefined && root.origin.kind !== "external") - out += ` — ${formatOrigin(scrub ? scrubOrigin(root.origin) : root.origin)}`; - return out; -} - -function heldWrite(write: HeldWrite, scrub: boolean): string { - return scrub || write.prev === undefined - ? write.name - : `${write.name} ${write.prev} → ${write.value}`; -} - -// --- Scrubbing ----------------------------------------------------------------- -// -// A trace recorded in production may be shared. The posture: no value -// previews (a signal's `prev`/`value` is application data), and no element -// text except on a `button` or `a` (what the user pressed is the point of -// an interaction record; the text of a `div` they clicked is content). - -const TARGET_TEXT = /^(\w+)((?:#[^\s"]+|\[name=[^\]]+\])?) "(.*)"$/; - -function scrubTarget(target: string | undefined): string | undefined { - if (target === undefined) return undefined; - const m = TARGET_TEXT.exec(target); - if (m === null) return target; - return m[1] === "button" || m[1] === "a" ? target : m[1] + m[2]; -} - -function scrubOrigin(origin: ChangeOrigin): ChangeOrigin { - if (origin.target === undefined) return origin; - const target = scrubTarget(origin.target); - return target === origin.target ? origin : { ...origin, target }; -} - -function scrubCause(cause: ChangeRecord): ChangeRecord { - const out: ChangeRecord = { ...cause }; - delete out.prev; - delete out.value; - if (out.origin !== undefined) out.origin = scrubOrigin(out.origin); - if (out.causes !== undefined) out.causes = out.causes.map(scrubCause); + out += ` — ${formatOrigin(root.origin)}`; return out; } -function scrubRerun(event: RerunEvent): RerunEvent { - return { ...event, causes: event.causes.map(scrubCause) }; +function heldWrite(write: HeldWrite): string { + return write.prev === undefined ? write.name : `${write.name} ${write.prev} → ${write.value}`; } diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 1c496f776..815480256 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -117,10 +117,10 @@ export interface RequestEvent { export type { CookieOptions } from "./cookies.js"; -// This runtime's records on `OBSERVE.records` (`"invocation"`, `"call"`, -// `"frame"`), and with them the `HostRecordTypes` augmentation that module -// declares: the published types resolve to this entry under every -// condition, so this re-export is what puts the augmentation in a +// This runtime's records on `OBSERVE.records` (`"invocation"`, `"render"`, +// `"call"`, `"frame"`), and with them the `HostRecordTypes` augmentation +// that module declares: the published types resolve to this entry under +// every condition, so this re-export is what puts the augmentation in a // consumer's program. export type { CallEvent, @@ -133,7 +133,10 @@ export type { FrameProducedEvent, InvocationEvent, InvocationListener, - InvocationLive + InvocationLive, + RenderEvent, + RenderListener, + RenderLive } from "./observe.js"; // The trace context's types (`getTraceContext()`, `OBSERVE.server.trace`), // with the `ServerObserve.trace` augmentation, for the same reason. diff --git a/packages/web/src/index.server.ts b/packages/web/src/index.server.ts index 07f5ec11b..d52a40b19 100644 --- a/packages/web/src/index.server.ts +++ b/packages/web/src/index.server.ts @@ -36,7 +36,10 @@ export type { FrameProducedEvent, InvocationEvent, InvocationListener, - InvocationLive + InvocationLive, + RenderEvent, + RenderListener, + RenderLive } from "./observe.js"; export type { TraceContext, TraceProvider } from "./trace.js"; export type { JSX } from "../jsx/jsx.js"; diff --git a/packages/web/src/observe.ts b/packages/web/src/observe.ts index d7262ac2d..7c178bf9f 100644 --- a/packages/web/src/observe.ts +++ b/packages/web/src/observe.ts @@ -3,8 +3,8 @@ // either platform, and the emitters for the client's — the `"call"` record // (a server-function call made from the browser) and the client half of the // `"frame"` record (a frame stream applied). The server's emitters — the -// `"invocation"` record and the frame's server half — are in -// server-observe.ts, which needs the server runtime; this module needs +// `"invocation"` and `"render"` records and the frame's server half — are +// in server-observe.ts, which needs the server runtime; this module needs // nothing of either platform's runtime, so every entry bundles it. // // The channel is reached by its REGISTERED SYMBOL, not by importing @@ -20,6 +20,7 @@ // gates): prod never reaches for the channel. import type { ChangeOrigin, Records } from "solid-js"; import type { RequestEvent } from "./server.js"; +import type { TraceContext } from "./trace.js"; // Replaced per build; a module const (not an inline literal) so the typed // gates below read as booleans (cookies.ts uses the same shape). @@ -122,6 +123,69 @@ export interface InvocationLive { export type InvocationListener = (event: InvocationEvent, live: InvocationLive) => void; +// --- "render": a server render, on the server ---------------------------------- + +/** + * One server render — a `renderToString` or a `renderToStream` (a document, + * or a frame stream over the same core) — delivered on + * `OBSERVE.records.subscribe("render", …)` once it ended: the document + * returned, the stream's last fragment written, or the render torn down. + * The server-side account of the head's timing: what the shell cost, and + * how many `` boundaries it waited on (each also a `"boundary"` + * record) — the facts the response's `Server-Timing` `solid-shell` metric + * is projected from. Serializable; the request the render served and its + * trace ride beside it in `RenderLive`. + */ +export interface RenderEvent { + /** `"string"` for `renderToString`, `"stream"` for `renderToStream`. */ + mode: "string" | "stream"; + /** `performance.now()` when the render began. */ + at: number; + /** + * Render start → the shell complete, in milliseconds: for a stream, the + * head and shell handed to the sink (the head is frozen from here — a + * fragment can no longer add to it); for a string, the document assembled + * (the whole render). What `solid-shell` carries. Absent when the render + * ended before its shell — torn down or failed pre-shell. + */ + shellMs?: number; + /** + * Render start → the render's end, in milliseconds: the document + * returned (`"string"`, equal to `shellMs`), the stream complete (every + * fragment written), or the teardown for the other outcomes. + */ + durationMs: number; + /** + * `` boundaries the shell waited on — discovered with pending + * async and settled before the shell completed, each a `"boundary"` + * record with `streamed: false` and a `solid-boundary` metric. A boundary + * that settled after the shell streamed as a fragment and is not counted + * here (its own record says `streamed: true`). Counted from the + * `"boundary"` records the render filed, under that record's gate: in an + * observe build with a `"render"` listener but no `"boundary"` listener + * the boundaries are not measured and this is `0`. + */ + boundaries: number; + /** + * `"complete"` — the render ran to its end; `"abandoned"` — the consumer + * left mid-stream (the sink threw, the readable was cancelled — the + * `SSR_STREAM_ABANDONED` finding is that request's account) and the + * render was torn down; `"error"` — the render failed: a string render + * threw, a stream's uncontained failure wound it down through `onError`. + */ + outcome: "complete" | "abandoned" | "error"; +} + +/** The live half of a render record. */ +export interface RenderLive { + /** The request event the render ran under; absent for a render outside a request scope. */ + event?: RequestEvent; + /** The trace the render belongs to — `getTraceContext()`'s answer for it. */ + trace: TraceContext; +} + +export type RenderListener = (event: RenderEvent, live: RenderLive) => void; + // --- "call": a server-function call, from the client ------------------------- /** @@ -309,6 +373,8 @@ declare module "solid-js" { interface HostRecordTypes { /** Server-function executions, on the server — see `InvocationEvent`. */ invocation: { event: InvocationEvent; live: InvocationLive }; + /** Server renders — a document or a frame stream — see `RenderEvent`. */ + render: { event: RenderEvent; live: RenderLive }; /** Server-function calls, from the client — see `CallEvent`. */ call: { event: CallEvent; live: CallLive }; /** Frame streams, produced or applied — see `FrameEvent`. */ diff --git a/packages/web/src/server-observe.ts b/packages/web/src/server-observe.ts index ce575a080..be389d2dd 100644 --- a/packages/web/src/server-observe.ts +++ b/packages/web/src/server-observe.ts @@ -1,9 +1,11 @@ // The server runtime's emitters on `OBSERVE.records`: the `"invocation"` -// record (a server-function execution, either dispatch leg) and the server -// half of the `"frame"` record (a frame stream produced). The record types -// and the channel accessor are observe.ts's, shared with the client's -// emitters; this module is the half that needs the server runtime (the -// render's hydration context, for the boundary a direct call ran under). +// record (a server-function execution, either dispatch leg), the `"render"` +// record (a `renderToString`/`renderToStream` render) and the server half +// of the `"frame"` record (a frame stream produced). The record types and the channel +// accessor are observe.ts's, shared with the client's emitters; this module +// is the half that needs the server runtime (the render's hydration +// context, for the boundary a direct call ran under; the request's trace +// record, which the `Server-Timing` metrics are projected from). // // Everything here folds out of the prod server artifacts behind the // `"_SOLID_OBSERVE_"` literal in `records()`: prod never reaches for the @@ -21,29 +23,94 @@ import { type FrameLive, type FrameProducedEvent, type InvocationEvent, - type InvocationLive + type InvocationLive, + type RenderEvent, + type RenderLive } from "./observe.js"; -import { traceForEvent } from "./trace.js"; +import { traceForEvent, type TraceRecord } from "./trace.js"; import type { RequestEvent } from "./server.js"; // Replaced per build; a module const so the gates below read as booleans. const IS_DEV = "_SOLID_DEV_" as unknown as boolean; /** - * Whether the runtime times the server work it records for THIS request's - * `Server-Timing` (see `TimingMetric` in trace.ts) — the invocation's and - * the document's shell and boundaries. Dev builds always (the panel's - * home, and the boundary already measures for its dev checks); observe - * builds while a listener is on the record the same measurement feeds, so - * an app with no observer sees no wire change. `type` names that record. - * `false` in prod, where `records()` is `undefined`. + * The one gate for a server record and the `Server-Timing` metric projected + * from it (`TimedWork` in trace.ts): whether the runtime builds the record + * of `type` — `"invocation"` (→ `solid-invocation`), `"render"` (→ + * `solid-shell`), `"boundary"` (→ `solid-boundary`, gated in the reactive + * library's boundary with this same rule). Dev builds always: dev is the + * panel's home, and the boundary already measures for its dev checks. + * Observe builds while a listener is on the type (`observed`), so an app + * with no observer sees no wire change; the record is then delivered AND + * the metric written from it — one clock, one object. `false` in prod, + * where `records()` is `undefined`. */ -export function timesServerWork(type: "invocation" | "boundary"): boolean { +export function timesServerWork(type: "invocation" | "render" | "boundary"): boolean { const channel = records(); if (channel === undefined) return false; return IS_DEV || channel.observed(type); } +/** What the renderer holds while a render is being recorded. */ +export interface RenderObservation { + /** + * The shell is complete — for a stream, handed to the sink; for a string, + * the document assembled: stamps `shellMs`, the `solid-shell` metric's + * duration. Once; a later call is ignored. + */ + shell(): void; + /** A `` boundary the shell waited on settled: counts it (`boundaries`). */ + boundary(): void; + /** The render ended; delivers the record. Once. */ + settle(outcome: RenderEvent["outcome"]): void; +} + +/** + * Opens the `"render"` record for a render of `mode` — on the request's + * trace record, where the head commit reads it (`TraceRecord.render`) — + * when `timesServerWork("render")`; `undefined` otherwise, and the renderer + * then does nothing extra, not even read the clock. The record is built as + * the render proceeds and delivered at `settle`, to a listener if there is + * one; `event` is the request the render serves, the record's live half. + */ +export function observeRender( + trace: TraceRecord, + mode: RenderEvent["mode"], + event: RequestEvent | undefined +): RenderObservation | undefined { + if (!timesServerWork("render")) return undefined; + const record: RenderEvent = { + mode, + at: performance.now(), + durationMs: 0, + boundaries: 0, + outcome: "complete" + }; + trace.render = record; + let settled = false; + return { + shell() { + // Once, and never on a delivered record (a render wound down as its + // shell was being handed over settled without one). + if (!settled && record.shellMs === undefined) record.shellMs = performance.now() - record.at; + }, + boundary() { + if (!settled) record.boundaries++; + }, + settle(outcome) { + if (settled) return; + settled = true; + record.durationMs = performance.now() - record.at; + record.outcome = outcome; + const channel = records()!; + if (!channel.observed("render")) return; + const live: RenderLive = { trace: trace.context }; + if (event !== undefined) live.event = event; + channel.emit("render", record, live); + } + }; +} + /** What the runtime passes an observation from either dispatch leg. */ export interface InvocationContext { id: string; @@ -113,32 +180,26 @@ function deliver( outcome: "ok" | "error", value: unknown ): void { - const durationMs = performance.now() - at; - // The request's `Server-Timing` (trace.ts): the execution, on the - // response it produces — or on the document, for a direct call made - // during its render. Recorded before the record is delivered, so a - // listener that commits the response from its callback still ships it. - traceForEvent(context.event).timing.push({ - name: "solid-invocation", - dur: durationMs, - desc: context.id - }); - const channel = records()!; - if (!channel.observed("invocation")) return; const record: InvocationEvent = { id: context.id, direct: context.direct, at, - durationMs, + durationMs: performance.now() - at, outcome }; if (boundary !== undefined) record.boundary = boundary; + if (outcome === "ok" && isDeferredBody(value)) record.deferred = true; + // The request's `Server-Timing` (trace.ts) projects the record — on the + // response the execution produces, or on the document for a direct call + // made during its render. Filed before the record is delivered, so a + // listener that commits the response from its callback still ships it. + traceForEvent(context.event).timing.push({ type: "invocation", event: record }); + const channel = records()!; + if (!channel.observed("invocation")) return; const live: InvocationLive = { event: context.event, args: context.args }; if (context.request !== undefined) live.request = context.request; - if (outcome === "ok") { - live.result = value; - if (isDeferredBody(value)) record.deferred = true; - } else live.error = value; + if (outcome === "ok") live.result = value; + else live.error = value; channel.emit("invocation", record, live); } diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 18e6e3409..dd18da2ff 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -34,7 +34,8 @@ import { traceMetaMarkup, type TraceContext } from "./trace.js"; -import { timesServerWork } from "./server-observe.js"; +import { observeRender, timesServerWork } from "./server-observe.js"; +import { records } from "./observe.js"; import { createHydrationSerializer, getLocalHeaderScript @@ -1762,8 +1763,9 @@ export function renderToString(code, options = {}) { const context = sharedConfig.context; const requestEvent = peekRequestEvent(); context.trace = requestEvent ? traceForEvent(requestEvent) : traceFor(context, undefined); - timeDocument(context, context.trace); + const render = timeDocument(context, context.trace, "string", requestEvent); let dispose; + let rendered = false; try { const html = root( d => { @@ -1800,11 +1802,19 @@ export function renderToString(code, options = {}) { // through. A render that threw leaves the head open: its declarations // retract with the dispose below, and the handler's error path may // still write. + // + // A string render's shell is the whole document: complete here, so the + // commit below reads its `shellMs` for `solid-shell`. + if (render) render.shell(); if (requestEvent && requestEvent.response) { commitResponseStub(requestEvent.response, { event: requestEvent }); } + rendered = true; return document; } finally { + // The render record settles before the trace is let go: a listener + // reading `getTraceContext()` from its callback finds the render's. + if (render) render.settle(rendered ? "complete" : "error"); // Release the graph before returning (#3385): a deferred dispose held // every root — and every memo under it — until the next macrotask, so // nothing was freed across a synchronous loop of renders. @@ -1894,6 +1904,9 @@ export function renderToStream(code, options = {}) { const requestEvent = peekRequestEvent(); let dispose; let dead = false; + // The render's `"render"` record (`timeDocument`, once the context is up): + // its shell stamped at `doShell`, settled at `onDone` or by the wind-down. + let render; // The serializer (created below, once the sink is assembled) — hoisted so // the wind-down can close it. `abandon` is only ever reached after the // render starts, by which point it is assigned; the hoist keeps that from @@ -1972,6 +1985,9 @@ export function renderToStream(code, options = {}) { dead = true; completed = true; if (disconnect) disconnected = true; + // The render's record ends here, with how: the client left, or the + // render failed (the render error's finding says why). + if (render) render.settle(disconnect ? "abandoned" : "error"); // The live sink wrapper (post-shell) is handed to the failure // completion below; pre-shell there is none yet. const sink = writable; @@ -2183,6 +2199,9 @@ export function renderToStream(code, options = {}) { }); writable && writable.end(); completed = true; + // The stream is whole: the render record settles (its shell was stamped + // by `doShell` above), before the graph it describes is released. + if (render) render.settle("complete"); if (firstFlushed) dispose(); }; // FrameSink seam (design in frame-sink.js): semantic emission routes through @@ -2791,7 +2810,7 @@ export function renderToStream(code, options = {}) { // pass so the per-component context clones carry it; cleared at completion // (below) so a read outside any render never finds a stale one. context.trace = requestEvent ? traceForEvent(requestEvent) : traceFor(context, undefined); - timeDocument(context, context.trace); + render = timeDocument(context, context.trace, "stream", requestEvent); registerEntryAssets(manifest); let html = root( @@ -2888,6 +2907,11 @@ export function renderToStream(code, options = {}) { noScripts, traceMetaMarkup(context.trace) ); + // The shell is complete — html and head resolved, about to be handed to + // the sink: the render record's `shellMs` is final here, BEFORE the + // handoff, because the response head commits from inside the sink's + // first write (`createSSRResponse`) and projects `solid-shell` from it. + if (render) render.shell(); // `preloads`, `preloadLinks` and `inlineStyles` are the LIVE tracking // containers, not snapshots: a post-shell registration pushes into them // AND arrives separately through `sink.asset`. Consume them inside this @@ -5496,18 +5520,30 @@ function peekRequestEvent() { // answered); see trace.ts. /** - * Opens the document's timed server work for the response's `Server-Timing` - * (trace.ts `TimingMetric`): the shell — render start to head commit, - * measured at the commit — and the `` boundaries that settle - * before it, which the reactive library's boundary pushes onto the render - * context (`_timing`, the seam; it formats nothing). Only while the runtime - * times boundaries anyway (`timesServerWork`): dev, or an observe build - * with a `"boundary"` listener. + * Opens the render's recording, from which the response's `Server-Timing` + * metrics are projected at head commit (trace.ts `TimedWork`): the + * `"render"` record (`observeRender` — `solid-shell` is its `shellMs`, + * stamped when the shell completes) under its own gate, and the seam the + * reactive library's boundary files its `"boundary"` records through + * (`_recordBoundary` on the render context — it formats nothing) for + * `solid-boundary`. The seam is installed under the boundary record's own + * gate (`timesServerWork("boundary")`, the rule the boundary applies before + * building one): the two sides of one measurement agree by construction, + * and a render context from a build tier the boundary's differs from (a + * test harness) cannot make the header say what no listener asked for. + * Returns the render observation, or `undefined` when nothing records the + * render; a closure in observe builds, nothing in prod. */ -function timeDocument(context, trace) { - if (!timesServerWork("boundary")) return; - trace.shellStart = performance.now(); - context._timing = trace.timing; +function timeDocument(context, trace, mode, requestEvent) { + if (!"_SOLID_OBSERVE_" || records() === undefined) return undefined; + const render = observeRender(trace, mode, requestEvent); + if (timesServerWork("boundary")) { + context._recordBoundary = event => { + trace.timing.push({ type: "boundary", event }); + if (render) render.boundary(); + }; + } + return render; } /** diff --git a/packages/web/src/trace.ts b/packages/web/src/trace.ts index ac6b215dd..ec0f5e57e 100644 --- a/packages/web/src/trace.ts +++ b/packages/web/src/trace.ts @@ -34,7 +34,8 @@ // State hangs off the shared `OBSERVE` object under a registered symbol for // the same reason as `server-observe.ts`: each server bundle carries its own // copy of this module. -import { OBSERVE, type ServerTrace } from "solid-js"; +import { OBSERVE, type BoundaryEvent, type ServerTrace } from "solid-js"; +import type { InvocationEvent, RenderEvent } from "./observe.js"; /** * The trace the current request belongs to — continued from the incoming @@ -93,39 +94,38 @@ declare module "solid-js" { } /** - * A timed span of the request's server work, carried to the browser as a - * `Server-Timing` metric (`;dur=;desc=""`) beside the trace - * entries — see `appendTraceServerTiming`. Recorded only where the runtime - * is already measuring: in dev builds, and in observe builds while a - * listener is on the record the same measurement feeds (`"invocation"`, - * `"boundary"`); the header is a second reader of one clock, so an app - * with no observer sees no wire change (the trace's rule). What rides is - * what the server knew when the head left — the function that produced a - * response; for a document, the shell render and the boundaries that + * The request's server work as recorded — the `"invocation"` and + * `"boundary"` records made while it was served, in completion order — + * from which the `Server-Timing` metrics are projected at head commit + * (`appendTraceServerTiming`): `solid-invocation;dur=;desc=""` + * for an execution, `solid-boundary;dur=;desc=""` for + * a `` boundary the shell waited on (the `"render"` record on the + * same `TraceRecord` gives `solid-shell;dur=`). One gate for record + * and metric alike: a record is built in dev builds always, and in observe + * builds while a listener is on its type (`OBSERVE.records.observed`), so + * the header is a projection of what was recorded, never a second clock — + * an app with no observer sees no wire change (the trace's rule). What + * rides is what the server knew when the head left — the function that + * produced a response; for a document, the shell and the boundaries that * settled inside it. The Performance-panel adapter paints these under the * matching client span (`@solidjs/web/performance-tracks`). */ -export interface TimingMetric { - /** `solid-invocation` | `solid-shell` | `solid-boundary` — an RFC 9110 token. */ - name: string; - /** Milliseconds. */ - dur: number; - /** The function id, the boundary's component label — what the span is labelled. */ - desc?: string; -} +export type TimedWork = + | { type: "invocation"; event: InvocationEvent } + | { type: "boundary"; event: BoundaryEvent }; /** A derived trace plus whether the browser is told about it (see the header note). */ export interface TraceRecord { context: TraceContext; emit: boolean; - /** The request's timed server work, in completion order (see `TimingMetric`). */ - timing: TimingMetric[]; + /** The request's recorded server work, in completion order (see `TimedWork`). */ + timing: TimedWork[]; /** - * `performance.now()` when the document render began, while the shell is - * timed and the head has not committed: the `solid-shell` metric is - * measured at commit, from here. + * The render this request is serving, while one is being recorded — the + * `"render"` record as it fills (`RenderEvent`): `solid-shell` is its + * `shellMs`, once the shell is complete. */ - shellStart?: number; + render?: RenderEvent; } // Replaced per build; a module const so the gates below read as booleans. @@ -343,17 +343,23 @@ function quoteDesc(value: string): string { .replace(/[\\"]/g, m => "\\" + m); } +/** The shell metric's duration, once the render's shell is complete. */ +function shellMs(record: TraceRecord): number | undefined { + return record.render !== undefined ? record.render.shellMs : undefined; +} + /** Whether `appendTraceServerTiming` has anything to write for `record`. */ export function hasServerTiming(record: TraceRecord): boolean { - return record.emit || record.timing.length > 0 || record.shellStart !== undefined; + return record.emit || record.timing.length > 0 || shellMs(record) !== undefined; } /** * Appends the record to `headers` as `Server-Timing` metrics — its trace * entries as `;desc=""` when the browser is told (see - * `TraceRecord`), never duplicating a name the app already wrote; and its - * timed server work as `;dur=;desc=""` (see `TimingMetric`), - * the shell's duration measured here, at the moment the head freezes. + * `TraceRecord`), never duplicating a name the app already wrote; and the + * server work it recorded as `;dur=;desc=""`, each metric a + * projection of one record (see `TimedWork`): `solid-shell` first, from the + * render record, then the invocations and boundaries in completion order. * Metrics repeat their names by design (one `solid-boundary` per boundary), * so they are not name-deduplicated. Must run before the response head * commits. @@ -370,20 +376,32 @@ export function appendTraceServerTiming(headers: Headers, record: TraceRecord): present.add(name.toLowerCase()); } } - if (record.shellStart !== undefined) { - // First in the list, before the boundaries it contains: measured once, - // at the first commit (a stream's shell flush; a string render's end). - const shell = { name: "solid-shell", dur: performance.now() - record.shellStart }; - record.shellStart = undefined; - headers.append("Server-Timing", formatMetric(shell)); - } - for (const metric of record.timing) headers.append("Server-Timing", formatMetric(metric)); + const shell = shellMs(record); + if (shell !== undefined) headers.append("Server-Timing", formatMetric("solid-shell", shell)); + for (const work of record.timing) headers.append("Server-Timing", metricOf(work)); +} + +/** The `Server-Timing` metric one recorded piece of server work projects to. */ +function metricOf(work: TimedWork): string { + if (work.type === "invocation") + return formatMetric("solid-invocation", work.event.durationMs, work.event.id); + // Labelled by owner path, the label the client's `fallback` record and + // the findings carry — ASCII ` > ` on the wire (a header value is a byte + // string; the adapter renders the artifact's ` › `); the hydration id when + // the runtime knows no names. + const boundary = work.event; + return formatMetric( + "solid-boundary", + boundary.durationMs, + boundary.ownerPath !== undefined ? boundary.ownerPath.join(" > ") : boundary.id + ); } -function formatMetric(metric: TimingMetric): string { +/** `;dur=;desc=""` — `name` an RFC 9110 token, `desc` quoted and ASCII-sanitised. */ +function formatMetric(name: string, dur: number, desc?: string): string { // One decimal: the panel's resolution; a header is not a profiler. - let out = `${metric.name};dur=${Math.round(metric.dur * 10) / 10}`; - if (metric.desc) out += `;desc="${quoteDesc(metric.desc)}"`; + let out = `${name};dur=${Math.round(dur * 10) / 10}`; + if (desc) out += `;desc="${quoteDesc(desc)}"`; return out; } diff --git a/packages/web/test/observe.type-tests.ts b/packages/web/test/observe.type-tests.ts index 55f73bd3e..f918c6ecd 100644 --- a/packages/web/test/observe.type-tests.ts +++ b/packages/web/test/observe.type-tests.ts @@ -33,6 +33,9 @@ import type { FrameProducedEvent, InvocationEvent, InvocationLive, + RenderEvent, + RenderLive, + RequestEvent, TraceContext, TraceProvider } from "@solidjs/web"; @@ -61,6 +64,7 @@ type Declared = | "boundary" | "recovery" | "invocation" + | "render" | "call" | "frame"; const declared: Declared = "boundary" as RecordType; @@ -83,6 +87,20 @@ observe.records.subscribe("invocation", (event, live) => { live.args satisfies unknown[]; }); +// The server's document render record — what `solid-shell` is projected from. +observe.records.subscribe("render", (event, live) => { + event satisfies RenderEvent; + live satisfies RenderLive; + event.mode satisfies "string" | "stream"; + event.at satisfies number; + event.shellMs satisfies number | undefined; + event.durationMs satisfies number; + event.boundaries satisfies number; + event.outcome satisfies "complete" | "abandoned" | "error"; + live.trace satisfies TraceContext; + live.event satisfies RequestEvent | undefined; +}); + // solid-js's own record merges onto the same channel, from its module. observe.records.subscribe("boundary", (event, live) => { event satisfies BoundaryEvent; diff --git a/packages/web/test/performance-tracks.spec.tsx b/packages/web/test/performance-tracks.spec.tsx index d45c6721d..080261410 100644 --- a/packages/web/test/performance-tracks.spec.tsx +++ b/packages/web/test/performance-tracks.spec.tsx @@ -855,7 +855,7 @@ describe("enablePerformanceTracks", () => { expect(holdSpan.properties).toEqual( expect.arrayContaining([ ["Held", "10.00ms"], - ["Held writes", "page 1 → 2"], // dev: previews shown (see the scrub test) + ["Held writes", "page 1 → 2"], // the engine's dev default `values: "full"`: previews shown ["Acknowledged by", "nothing"], ["Verdict", "silent hold — no feedback while waiting"], ["Interaction", formatOrigin(interaction.origin)] @@ -1381,9 +1381,12 @@ describe("enablePerformanceTracks", () => { expect(attribution.history("rerun")).toEqual([]); }); - test("scrub: no value previews, no element text except on a button or a link", () => { + test("values: the adapter paints what the engine recorded — `attribution.values` governs it at the source", () => { const { on } = measures(); - enable({ scrub: true }); + // The adapter scrubs nothing itself: the hold's `values` level decides + // what the records carry (see the engine's attribution-values tests), + // and the timeline shows exactly that. + enable({ attribution: { values: "labels" } }); const { interactions } = records(); const [n, setN] = createSignal(0, { name: "n" }); createRoot(() => createRenderEffect(n, () => {}, { name: "reader" })); @@ -1403,8 +1406,9 @@ describe("enablePerformanceTracks", () => { expect(labels).toContain("click on div#card"); expect(labels).toContain('click on button#save "Save"'); expect(labels.some(l => l.includes("Personal note"))).toBe(false); - // The formatter itself, unscrubbed, would have said more: - expect(formatOrigin(interactions[0].origin)).toContain("Personal note"); + // The record itself never carried the text; the formatter has nothing + // more to say than the label did. + expect(formatOrigin(interactions[0].origin)).toBe("click on div#card"); const [span] = rerunSpans(on("Effects"), "Effects"); expect(span.tooltip).not.toContain("0 → 1"); @@ -1534,32 +1538,6 @@ describe("enablePerformanceTracks", () => { expect(hot.issue).toBeUndefined(); }); - test("scrub: a finding's marker carries its code, kind and owner, not its sentence", () => { - const { marks } = measures(); - enable({ scrub: true }); - quiet(); - OBSERVE!.diagnostics.emit({ - code: "LONG_HOLD", - kind: "responsiveness", - severity: "error", - message: 'click on div#card "Personal note" waited 1200ms', - ownerPath: [""], - data: { holdMs: 1200 } - }); - const [mark] = marks; - expect(mark).toMatchObject({ - label: "LONG_HOLD — ", - color: "error", - tooltip: "responsiveness finding LONG_HOLD" - }); - expect(JSON.stringify(mark)).not.toContain("Personal note"); - expect(mark.properties!.some(([k]) => k === "Message" || k === "holdMs")).toBe(false); - expect(mark.issue).toMatchObject({ - severity: "error", - description: "responsiveness finding LONG_HOLD" - }); - }); - test("plain mode: a finding is the one-argument console.timeStamp — a Timings marker, now", () => { const { marks } = stamps(); enable({ rich: false }); diff --git a/packages/web/test/server/server-trace.spec.tsx b/packages/web/test/server/server-trace.spec.tsx index eb950dc19..dad1da8dc 100644 --- a/packages/web/test/server/server-trace.spec.tsx +++ b/packages/web/test/server/server-trace.spec.tsx @@ -29,7 +29,15 @@ import { useHead } from "@solidjs/web"; import { createMemo } from "solid-js"; -import type { RequestEvent, ResponseStub, TraceContext, TraceProvider } from "@solidjs/web"; +import type { RecordListener } from "solid-js"; +import type { + RenderEvent, + RenderLive, + RequestEvent, + ResponseStub, + TraceContext, + TraceProvider +} from "@solidjs/web"; // Server-only integration seam with no client mock; the same module the // `@solidjs/web` alias resolves through (index.server.ts re-exports it). import { commitResponseStub } from "../../src/server.js"; @@ -616,21 +624,28 @@ describe(" tags in the shell", () => { }); }); -// The request's timed server work as `Server-Timing` metrics -// (`TimingMetric` in trace.ts; painted by `@solidjs/web/performance-tracks` -// under the matching client span): `solid-shell` and the `solid-boundary`s -// the shell waited on, on the document; `solid-invocation` on a -// server-function response. Gated like the trace: the dev tier carries them -// always; an observe build only while a listener is on the record the same -// measurement feeds, so an app with no observer sees no wire change. +// The request's timed server work as `Server-Timing` metrics — each a +// PROJECTION of a record on `OBSERVE.records` (`appendTraceServerTiming` in +// trace.ts reads the record objects; painted by +// `@solidjs/web/performance-tracks` under the matching client span): +// `solid-shell` from the `"render"` record's `shellMs` and a +// `solid-boundary` from each `"boundary"` record the shell waited on, on +// the document; `solid-invocation` from the `"invocation"` record on a +// server-function response. One gate per metric, the record's own: the dev +// tier carries them always; an observe build only while a listener is on +// the record the metric is projected from, so an app with no observer sees +// no wire change. describe("Server-Timing: the request's timed work", () => { const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); const unsubscribes: Array<() => void> = []; afterEach(() => { for (const off of unsubscribes.splice(0)) off(); }); - const listen = (type: "boundary" | "invocation") => { - unsubscribes.push(OBSERVE!.records.subscribe(type, () => {})); + const listen = ( + type: T, + listener: RecordListener = () => {} + ) => { + unsubscribes.push(OBSERVE!.records.subscribe(type, listener)); }; /** A boundary that waits, and holds the shell for its content (`deferStream`). */ @@ -752,7 +767,7 @@ describe("Server-Timing: the request's timed work", () => { expect(serverTiming(observed.headers).size).toBe(0); }); - test("the document on the observe tier: nothing without a boundary listener, the shell and its boundaries with one", async () => { + test("the document on the observe tier: each metric rides its own record's gate", async () => { function Page() { return ( @@ -762,12 +777,158 @@ describe("Server-Timing: the request's timed work", () => { ); } + // No listener: nothing is recorded, the wire is unchanged. const quiet = await respond(observeWeb, event(), () => ); expect(quiet.has("server-timing")).toBe(false); + // A boundary listener gates `solid-boundary` alone: the shell is the + // `"render"` record's, which nobody asked for. + const offBoundary = OBSERVE!.records.subscribe("boundary", () => {}); + const boundaryOnly = await respond(observeWeb, event(), () => ); + expect(timingMetrics(boundaryOnly).map(m => m.name)).toEqual(["solid-boundary"]); + offBoundary(); + + // A render listener gates `solid-shell` alone. + listen("render"); + const renderOnly = await respond(observeWeb, event(), () => ); + expect(timingMetrics(renderOnly).map(m => m.name)).toEqual(["solid-shell"]); + + // Both: the shell first, then the boundary it waited on — the dev + // tier's list. listen("boundary"); - const observed = await respond(observeWeb, event(), () => ); - expect(timingMetrics(observed).map(m => m.name)).toEqual(["solid-shell", "solid-boundary"]); + const both = await respond(observeWeb, event(), () => ); + expect(timingMetrics(both).map(m => m.name)).toEqual(["solid-shell", "solid-boundary"]); + }); + + // The `"render"` record — the document render on `OBSERVE.records` — and + // its `solid-shell` projection: the header reads the record's `shellMs`, + // so the two agree by construction. + describe('the "render" record', () => { + const rendered: [RenderEvent, RenderLive][] = []; + afterEach(() => rendered.splice(0)); + const capture = () => listen("render", (event, live) => rendered.push([event, live])); + + test("a streamed document: one record at completion, shellMs the head's wait, boundaries counted", async () => { + function Page() { + return ( + + …}> + in-shell + + …}> + streamed + + + ); + } + capture(); + const evt = event({ traceparent: INCOMING }); + const stream = inScope(evt, () => renderToStream(() => )); + const response = await createSSRResponse(stream, evt); + // The head is committed at the shell; the record settles when the + // stream completes — after the late boundary. + const [shell] = timingMetrics(response.headers); + expect(shell.name).toBe("solid-shell"); + expect(rendered).toHaveLength(0); + await response.text(); + expect(rendered).toHaveLength(1); + const [record, live] = rendered[0]; + expect(record.mode).toBe("stream"); + expect(record.outcome).toBe("complete"); + expect(record.at).toBeGreaterThan(0); + // The shell waited for the held boundary and the whole render for the + // streamed one, in that order. + expect(record.shellMs).toBeGreaterThanOrEqual(14); + expect(record.durationMs).toBeGreaterThanOrEqual(record.shellMs!); + expect(record.durationMs).toBeGreaterThanOrEqual(29); + // The boundary the shell waited on; the streamed one is not the + // shell's cost (its own record says `streamed: true`). + expect(record.boundaries).toBe(1); + // The header's `solid-shell` IS the record's `shellMs`, to the tenth + // the wire carries. + expect(shell.dur).toBe(Math.round(record.shellMs! * 10) / 10); + // Live: the request and the trace the render ran under. + expect(live.event).toBe(evt); + expect(live.trace.traceId).toBe(TRACE_ID); + expect(live.trace).toBe(inScope(evt, () => getTraceContext())); + }); + + test("a string document: the shell is the whole document; no boundary held it", () => { + capture(); + const evt = event(); + const html = inScope(evt, () => renderToString(() => )); + const response = createSSRResponse(html, evt); + expect(rendered).toHaveLength(1); + const [record, live] = rendered[0]; + expect(record.mode).toBe("string"); + expect(record.outcome).toBe("complete"); + expect(record.boundaries).toBe(0); + expect(record.shellMs).toBeDefined(); + expect(record.durationMs).toBeGreaterThanOrEqual(record.shellMs!); + expect(live.event).toBe(evt); + const [shell] = timingMetrics(response.headers); + expect(shell).toMatchObject({ + name: "solid-shell", + dur: Math.round(record.shellMs! * 10) / 10 + }); + }); + + test("a render outside a request scope records too, with its own trace and no event", () => { + capture(); + renderToString(() => ); + expect(rendered).toHaveLength(1); + const [record, live] = rendered[0]; + expect(record.mode).toBe("string"); + expect(live.event).toBeUndefined(); + expect(live.trace.traceId).toMatch(HEX32); + }); + + test("a string render that throws settles as an error, with no shell", () => { + capture(); + const Boom = () => { + throw new Error("boom"); + }; + expect(() => renderToString(() => () as any)).toThrow("boom"); + expect(rendered).toHaveLength(1); + expect(rendered[0][0]).toMatchObject({ mode: "string", outcome: "error" }); + expect(rendered[0][0].shellMs).toBeUndefined(); + }); + + test("a stream the client abandons settles as abandoned", async () => { + const never = new Promise(() => {}); + function Stuck() { + const data = createMemo(async () => never); + return
{data()}
; + } + capture(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + let writes = 0; + // The sink dies on the shell's write: the client is gone (`SSR_STREAM_ABANDONED`). + renderToStream(() => ( + + …}> + + + + )).pipe({ + write() { + writes++; + throw new Error("EPIPE"); + }, + end() {} + }); + await delay(20); + expect(writes).toBeGreaterThan(0); + expect(rendered).toHaveLength(1); + const [record, live] = rendered[0]; + expect(record.outcome).toBe("abandoned"); + expect(record.mode).toBe("stream"); + // The shell was handed to the sink before the sink failed. + expect(record.shellMs).toBeDefined(); + // The one boundary never settled: not the shell's wait. + expect(record.boundaries).toBe(0); + expect(live.event).toBeUndefined(); + }); }); test("metrics fold beside a response's own Server-Timing, repeated names included", () => { diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index b03d2ec94..eb60ef80d 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -2274,7 +2274,23 @@ module.exports = [ // installs through the core's own `setAttributionHooks`. The guide URL // moved to `DEV.guideUrl`, so its string is off the observe object: the // tier scenario above is -51 B (17,648). The rest is mangler layout. - limit: "31.71 KB", + // (Rebased onto `next` after #3645/#3646 the same scenario measured + // 31,618 B, under the old cap; the ratchet is kept as measured.) + // `AttributionOptions.values` (2026-09-24): 31.71 -> 31.81 KB, measured + // at 31,804 B on `next` after #3649 (31,618; +186 B — on the original + // `records-channel` base it was +179 B, 31,689 -> 31,868; every prod + // scenario byte-identical, the tier scenario above -10 B of mangler + // layout). The engine now governs the + // user-data fields of its records at the source — `targetLabel` (the + // element text cut per level at `interactionStart`), the `values` gate + // on `stampWrite`'s previews and on `OPTIMISTIC_REVERTED`'s quoted + // values, and the least-permissive merge in `demanding` — in place of + // the performance-tracks adapter's post-hoc scrub (removed, not in this + // scenario). The default is the tier's, folded at build time (`__DEV__ + // ? "full" : "none"` — this artifact ships the literal `"none"`). A + // deliberate feature: the level is what an observe-tier holder relies + // on for its export contract. + limit: "31.81 KB", modifyEsbuildConfig: observeEsbuildConfig }, {