@jam-overture/ loom/ signals
Reader signals — what a published page reports about how it is read, and the parser a receiver checks them with.
Everything below is exported from that import. The names, the signatures and the sentences are read from the package itself rather than written here, so this page says what the copy of Loom in your node_modules says — and it changes in the same pull request the code does.
183 exports, in 23 modules. Generated from ./dist/signals/index.d.ts, which is the declaration file this package publishes for @jam-overture/loom/signals.
Nothing to install first. Everything this import loads arrives with @jam-overture/loom itself.
A narrower door opens onto part of this one.
If that is all you came for, import it instead and your program never loads the rest.
@jam-overture/loom/signals/broadcast publishes 14 of the 183 exports below — the same names, declared the same way. It does not load zod, which this import does, and its JavaScript goes through 10 of the package's built files where this one goes through 41.
No import here has everything behind it. @jam-overture/loom/signals publishes 183 of the 1,298 names this package publishes. The other 1,115 are behind one of the 16 other imports, and 14 of those 16 publish nothing this one does. The imports do not nest: a name that is not on this page is not a name that does not exist, and the search at the top of the page says which import it comes from.
Some of these names are published elsewhere too: 14 by @jam-overture/loom/signals/broadcast and 1 by @jam-overture/loom/telemetry — the same declarations reached through two doors, so either import gives you the same thing.
One name here means something else behind another door. horizonOf is published by @jam-overture/loom/telemetry as well, and it is declared differently there — the same name, not the same thing. Searching the name finds both, and only the signature tells them apart.
Broadcast
signals/broadcast
Broadcasting reader signals from a rendered Loom page.
READER_SIGNALS_EVENTvalue
const READER_SIGNALS_EVENT = "loom:signals"
VisibilityObservertype
Something that reports elements coming into and out of view. The browser's IntersectionObserver unless a host passes its own.
type VisibilityObserver = {
readonly observe: (element: Element) => void;
readonly disconnect: () => void;
};VisibilityEntrytype
type VisibilityEntry = {
readonly target: Element;
readonly visible: boolean;
};ObserveVisibilitytype
type ObserveVisibility = (onChange: (entries: readonly VisibilityEntry[]) => void) => VisibilityObserver;
ReaderSignalTypestype
The primitive types to report on: one list for every kind, or a list per kind.
type ReaderSignalTypes = readonly string[] | {
readonly [Kind in ReaderSignalKind]?: readonly string[];
};ReaderSignalOptionstype
type ReaderSignalOptions = {
/** Called with every batch. A throw or rejection here is contained and never reaches the page. */
readonly send?: (batch: ReaderSignalBatch) => void | Promise<void>;
/** Which kinds to broadcast. Every kind when absent. */
readonly kinds?: readonly ReaderSignalKind[];
/**
* Which primitive types to broadcast about. Every addressed type when absent.
*
* A list applies to every kind. An object sets it per kind — time on screen
* for sections, activations for links — and a kind it does not name is
* reported for every type, exactly as if `types` were absent for that kind.
* Whether a kind is reported at all is still `kinds`.
*/
readonly types?: ReaderSignalTypes;
/** How often a batch is delivered. Five seconds when absent. */
readonly flushEveryMs?: number;
/**
* How the broadcaster learns what is on screen. The browser's
* `IntersectionObserver` when absent; a test passes its own.
*/
readonly observeVisibility?: ObserveVisibility;
/**
* Whether a delegated signal carries the addressed nodes it happened inside.
* On when absent.
*
* `activated` and `disclosed` are filed against the control a reader aimed
* at, which is an addressed node of its own — so without the ancestry a
* deployment can report which button was pressed and never which region it
* was in. Off is for a host that reports on controls only and would rather
* not carry the walk in every batch.
*/
readonly within?: boolean;
/** The clock. `Date.now` when absent. */
readonly now?: () => number;
/**
* Where the view key's randomness comes from. The browser's
* `crypto.getRandomValues` when absent; a test passes its own so it can name
* the key it expects.
*/
readonly random?: RandomBytes;
};ReaderSignalBroadcasttype
type ReaderSignalBroadcast = {
/** Deliver whatever has been gathered, now. */
readonly flush: () => void;
/** Deliver what is left and stop observing. Safe to call twice. */
readonly stop: () => void;
};ReaderSignalBroadcastErrortype
type ReaderSignalBroadcastError = {
/** The root carries no tree id or revision — the page was not rendered with `addressed: true`. */
readonly code: "unaddressed";
readonly detail: string;
};broadcastReaderSignalsfunction
Start broadcasting reader signals from the page under root.
Shown in use on What your readers do — Two: start the broadcaster, in the browser
const broadcastReaderSignals: (root: Element, options?: ReaderSignalOptions) => Result<ReaderSignalBroadcast, ReaderSignalBroadcastError>
Change
signals/change
What a change did to the reading of a page.
ChangeSilencetype
Why a comparison answered nothing.
type ChangeSilence = /** * The two readings are of different trees. * * Nothing is comparable: not a pair, not a part, not a word. Two pages are * two pages, and the plausible-false-number failure here would be a screen * reporting that a change removed every part of a page it was never about. */ "different-trees" /** * One of the two windows held no views. * * Every standing on that side is `unknown` (`parts.ts`), so there is no share * to compare against — and *every fall was fixed* is what a naive subtraction * would say about a page nobody has opened yet. The parts census still * answers, because what the change did to the tree is a fact about the tree: * a reading may say *three bands moved and one was reworded, and nothing has * been measured since.* */ | "nothing-measured";
CHANGE_SILENCESvalue
const CHANGE_SILENCES: readonly ChangeSilence[]
describeChangeSilencefunction
One line per silence, for a surface putting the comparison in front of a person.
const describeChangeSilence: (silence: ChangeSilence) => string
PairFatetype
Why a pair of siblings has no counterpart in the other reading.
type PairFate = /** One of its two parts is not in the other revision at all: removed by the change, or added by it. */ "absent" /** Both parts are there and no longer children of the same node, so they are not siblings to compare. */ | "moved" /** * Both parts are still children of one node and no longer next to each other. * * Something was inserted between them, or a part between them that could not * anchor a stop now can. This is the fate worth looking at hardest: a change * made where readers were leaving shows up here and nowhere else. */ | "separated" /** * They are still adjacent and the other way round, so the fall between them * is a fall in the other direction. * * A reader meets them in the opposite order, which makes the two shares * answers to two different questions rather than two answers to one. Reported * once per side, because each orientation is an adjacency somebody really * read. */ | "reordered" /** * They are still adjacent, and one of them reported something other than * coming into view there. * * Its `reached` of 0 is an absence of evidence rather than an absence of * readers, so no fall can be measured between them on that side — and * a side-by-side that quietly read it as zero would report a cliff appearing * or vanishing that no reader walked off. */ | "unanchorable" /** * They are adjacent and anchorable on both sides, and on one side nobody * reached the first of them. * * `lost ÷ reached` has no value there, and the tempting reading is the * dangerous one: a share of 0.6 beside a share nobody can compute looks like a * fall that was fixed, when what happened is that readers stopped arriving. */ | "unreached";
PAIR_FATESvalue
const PAIR_FATES: readonly PairFate[]
describePairFatefunction
One line per fate, for a surface putting the comparison in front of a person.
const describePairFate: (fate: PairFate) => string
StopSidetype
What one reading said about one pair of siblings.
type StopSide = {
/** Views that reached the first of the pair, which is the denominator of `share`. */
readonly reached: number;
/** Views that reached the first and not the second. Zero where none did. */
readonly lost: number;
/** `lost ÷ reached`, in `[0, 1]`. Zero is a pair nobody stopped at. */
readonly share: number;
};ComparedStoptype
One pair of siblings, as two readings measured it.
type ComparedStop = {
/** The part they reached. */
readonly after: NodeId;
/** The next part in the run, which fewer reached. */
readonly before: NodeId;
/** The parent whose run they are both steps of, which is the same node in both readings. */
readonly parentId: NodeId;
readonly was: StopSide;
readonly now: StopSide;
/**
* `was.share − now.share`: the share of readers that no longer stop here.
*
* **Positive is better**, because a stop is a loss, and
* {@link ComparedStop.readersKept} carries the same sign for the same reason.
* A screen that subtracted the other way round would have one of its two
* figures pointing the wrong way, which is the defect a reviewer cannot see.
*/
readonly improvement: number;
/**
* The smallest share one reader could move, on whichever side counted fewer.
*
* `1 ÷ min(was.reached, now.reached)`. It is the resolution of the comparison
* and not a confidence interval: two readers out of three against one out of
* two is a third of a share point apart and is two readers, and no amount of
* arithmetic makes it more than that.
*/
readonly resolution: number;
/**
* Whether `improvement` is at least one reader's worth on the coarser side.
*
* False is *nothing happened here that a reader did*, and it is the field that
* keeps a sparsely-read page off the top of a screen. Evaluated in integers
* rather than by comparing two divisions, so a rounding error never decides
* whether a reader exists.
*/
readonly beyondOneReader: boolean;
/**
* `improvement × now.reached`: the readers the change no longer loses here, at
* the volume the page has now.
*
* The ranking key, and it ranks by readers rather than by share for the same
* reason a fall does — a stop two readers out of three abandoned is a
* worse rate and a smaller problem than one four hundred out of a thousand
* did. Not rounded, because it is a rate times a count and a magnitude rather
* than a census, and **never added across stops**: one reader is in every stop
* they walked past.
*/
readonly readersKept: number;
};IncomparablePairtype
A pair one reading measured and the other cannot be asked about.
type IncomparablePair = {
readonly after: NodeId;
readonly before: NodeId;
readonly parentId: NodeId;
/** Which reading this pair is from, and therefore which reading `reached` and `share` are off. */
readonly side: "was" | "now";
readonly fate: PairFate;
readonly reached: number;
readonly share: number;
};PartsChangedtype
What the change did to the page itself, as the two trees say it.
type PartsChanged = {
/** Element nodes in both revisions, {@link PartsChanged.moved} among them. */
readonly shared: number;
/** In the later revision and not the earlier one, in reading order. */
readonly added: readonly NodeId[];
/** In the earlier revision and not the later one, in reading order. */
readonly removed: readonly NodeId[];
/** Shared parts whose parent is a different node, in reading order. */
readonly moved: readonly NodeId[];
/**
* Shared parts whose own words are not the words they were, in reading order.
*
* **A floor and not a count.** A part's words are the props its type declared
* as copy and its direct text children, so a part whose type declares
* nothing has no words to compare and a rewording of it is invisible here.
* {@link PartsChanged.unreadable} is how many parts that is, which is what
* stops the floor from reading as a total.
*/
readonly reworded: readonly NodeId[];
/**
* Shared parts whose words were the same as far as this reading could see, and
* where something about them went unread on one side or the other.
*
* *Nobody has said whether these props are words* is a different answer from
* *these words did not change*, and keeping them apart is the bargain `copy`
* was declared under and the join carried into a reading.
*/
readonly unreadable: number;
};ReadingChangetype
type ReadingChange = {
/** The earlier reading's tree. The two agree unless `silence` is `different-trees`. */
readonly treeId: TreeId;
readonly revisions: {
readonly was: number;
readonly now: number;
};
/**
* The two progress readings this comparison is made of.
*
* Carried rather than left to the caller, because deriving them twice is how
* two screens come to disagree — and because the headline of each side is
* already on them: a consumer that wants *the steepest fall before, and what
* happened to it* takes `progress.was.steepest` and finds that pair in
* {@link ReadingChange.stops} or {@link ReadingChange.incomparable} by its two
* node ids.
*/
readonly progress: {
readonly was: ReadingProgress;
readonly now: ReadingProgress;
};
readonly parts: PartsChanged;
/** Every pair both readings could measure, in the earlier reading's reading order. */
readonly stops: readonly ComparedStop[];
/** Every pair only one of them could, each once, with the reason. */
readonly incomparable: readonly IncomparablePair[];
/**
* The pair the change keeps most readers at, among those beyond one reader.
*
* Null where no pair improved by more than a reader, which includes every page
* the change did not help.
*/
readonly mostKept: ComparedStop | null;
/** The pair it loses most readers at, on the same terms. Null where none worsened. */
readonly mostLost: ComparedStop | null;
/** Why nothing was compared, or `null` where something was. */
readonly silence: ChangeSilence | null;
};readingChangeOffunction
What a change did to the reading of a page, from a reading of each side.
const readingChangeOf: (was: PageReading, now: PageReading) => ReadingChange
Collect
signals/collect
The one operation that empties the buffer.
DEFAULT_READER_SIGNAL_WINDOW_MSvalue
The window is the age a batch reaches before it is counted and dropped, and it is two arguments pulling opposite ways.
const DEFAULT_READER_SIGNAL_WINDOW_MS: number
MIN_READER_SIGNAL_WINDOW_MSvalue
A minute, and the floor is not a style choice.
const MIN_READER_SIGNAL_WINDOW_MS: number
readerSignalWindowSchemaschema
const readerSignalWindowSchema: z.ZodObject<…>
ReaderSignalWindowtype
type ReaderSignalWindow = z.infer<typeof readerSignalWindowSchema>;
DEFAULT_COLLECTION_SCANvalue
How much of the buffer one run will look at.
const DEFAULT_COLLECTION_SCAN = 5000
MAX_COLLECTION_SCANvalue
const MAX_COLLECTION_SCAN = 50000
CollectionRequesttype
type CollectionRequest = {
readonly windowMs?: number;
/**
* The funnels this deployment asks about. Absent means none — a pair is a
* question somebody wrote down, and rollup does not invent questions.
*
* A pair added later applies to windows collected after it, never to the ones
* already folded, because the batches those answers came from are gone. That
* is the price of aggregates being the durable artefact and it is worth
* knowing before a deployment waits a day for a number it will not get.
*/
readonly pairs?: readonly FunnelPair[];
readonly clock?: Clock;
readonly scanLimit?: number;
};CollectionPlantype
What one run may take, as a pure function of batches and an instant.
type CollectionPlan = {
/** Old enough to count, in ascending `seq`. */
readonly ripe: readonly ReceivedBatch[];
/** The position to forget below, once the counters are durable. `null` when nothing is ripe. */
readonly before: number | null;
/** Seen by this scan and too young. A floor rather than a total — the scan stops at the first. */
readonly waiting: number;
};collectionPlanOffunction
const collectionPlanOf: (scanned: readonly ReceivedBatch[], horizon: string) => CollectionPlan
horizonOffunction
const horizonOf: (configured: ReaderSignalWindow, now: string) => string | null
CollectionSummarytype
The half of the outcome a caller shows or logs, with nothing in it about a reader.
type CollectionSummary = {
readonly counted: number;
readonly tallies: number;
readonly funnels: number;
/** Distinct page views the window held. */
readonly views: number;
/**
* Batches that carried no view key, whose occurrences counted and whose views
* did not. A rate shown without saying which denominator it used is the
* failure mode here, so the number travels with the run that produced it.
*/
readonly uncorrelated: number;
};CollectionOutcometype
type CollectionOutcome = {
readonly outcome: "collected";
readonly summary: CollectionSummary;
/** Rows the buffer removed. Below `counted` only if something else pruned first. */
readonly removed: number;
/** The scan filled its limit with ripe batches, so another run has work now. */
readonly more: boolean;
}
/** Nothing is old enough yet, which is the ordinary answer on a quiet deployment. */
| {
readonly outcome: "nothing-ripe";
readonly waiting: number;
}
/**
* The counters took the window and the buffer would not drop it.
*
* Its own outcome because it is the one state that gets worse if it is
* ignored: the batches are durable in the counters and still in the buffer,
* so the next run counts them again. An operator seeing this prunes below the
* reported position by hand, or fixes the buffer before the next run.
*/
| {
readonly outcome: "counted-not-forgotten";
readonly summary: CollectionSummary;
readonly before: number;
readonly error: ReaderSignalStoreError;
}
/** The window was not usable. Nothing was read, counted or deleted. */
| {
readonly outcome: "refused";
readonly reason: string;
}
/** Read or write failed before anything became durable. Nothing was deleted. */
| {
readonly outcome: "unavailable";
readonly error: ReaderSignalStoreError;
};collectReaderSignalsfunction
Count a window of the buffer into the durable counters, then drop it.
const collectReaderSignals: (journal: ReaderSignalJournal, store: ReaderTallyStore, request?: CollectionRequest) => Promise<CollectionOutcome>
describeCollectionfunction
One line for an operator, because an outcome nobody can read is one nobody acts on.
const describeCollection: (outcome: CollectionOutcome) => string
Copy
signals/copy
How much of what a page says gets read, and which of it nobody saw.
Passagetype
One part's own words, with what the window said about the part saying them.
type Passage = {
readonly nodeId: NodeId;
readonly type: PrimitiveType;
/** What part it plays, or `null` where its type declared none. */
readonly role: PrimitiveRole | null;
/** 0 at the root. */
readonly depth: number;
/** Carried through from the part, so a passage and a part say the same thing. */
readonly standing: PartStanding;
/** Distinct page views that reported the part coming into view; `0` where no row named it. */
readonly readers: number;
/**
* The words themselves, in reading order, exactly as the page says them.
*
* **Its own, never its subtree's**, so the text of two passages may be
* concatenated and no sentence is read twice.
*/
readonly text: readonly string[];
/** {@link text}, counted. */
readonly words: number;
/**
* Whether this passage is short of words it cannot see.
*
* True where the part's type declared no `copy`, or declared a prop holding
* something that is not a string. The words it does hold are still its
* own; what is not knowable is whether there are more.
*/
readonly floored: boolean;
};RoleCopytype
Every part playing one role, with the words they say between them.
type RoleCopy = {
/** `null` is the row for parts whose type declared no role. */
readonly role: PrimitiveRole | null;
/** Parts of this role that say something, which is the rule {@link CopyReading.passages} applies. */
readonly passages: number;
readonly words: number;
/**
* Words by the standing of the part saying them. The three add to
* {@link words}.
*/
readonly wordsByStanding: Readonly<Record<PartStanding, number>>;
/**
* The share of this role's words at least one reader reached. `null` where the
* role says nothing.
*
* Addable across roles — unlike every view count on a `RoleReading`, which is
* absent there because distinctness cannot be added. Words can: they
* are a property of the page rather than of the readers.
*/
readonly share: number | null;
/** Passages of this role whose words are a floor. */
readonly floored: number;
};CopySilencetype
Why there is no typical reader's figure.
type CopySilence =
/** The page says nothing that could have been read. */
"wordless"
/** The window held no views of this revision, so there is no reader to be typical of. */
| "unmeasured"
/**
* Some part's words are a floor, so a mean of them is neither a count nor a
* ceiling.
*
* The share survives a floor and says so; this does not. The numerator is
* short by the words nobody declared and the denominator is a view floor that
* is short the other way, so the two errors no longer both lean one way and
* the figure cannot be published as *at most*.
*/
| "floored"
/**
* A part reports more readers than the page has views, which no rollup
* produces.
*
* `reached` counts the views that saw a part and `views` the views that said
* anything about it, so a row's `reached` can never exceed its own `views` and
* {@link CopyReading.views} is the largest `views` of any row. A reading where
* this holds was assembled by hand or by a sender that is not one — the same
* alarm `PageReading.orphaned` is, at the one other place a figure here would
* stay plausible while being about nothing.
*/
| "inconsistent";COPY_SILENCESvalue
The closed set, for a surface that has to account for every reason a figure is absent.
const COPY_SILENCES: readonly CopySilence[]
describeCopySilencefunction
One line per silence, for a surface saying why a figure is not there.
const describeCopySilence: (silence: CopySilence) => string
CopyReadingtype
What one window of one revision says about how much of the page is read.
type CopyReading = {
readonly treeId: TreeId;
readonly revision: number;
/** The page's view floor, carried through from the reading unchanged. */
readonly views: number;
/**
* Every word this revision says, counted once.
*
* The one page-wide word figure in this subsystem, sound for the one reason
* the module documentation gives: these are the parts' own words, which
* partition the page, rather than the subtree words a pace reading nests.
*/
readonly words: number;
/** Words by the standing of the part saying them. The three add to {@link words}. */
readonly wordsByStanding: Readonly<Record<PartStanding, number>>;
/**
* The share of the page's words at least one reader reached. `null` where the
* page says nothing.
*
* *At least one*, which is what a standing of `read` means and not what a
* person reads this sentence as. {@link typical} is the per-reader figure.
*/
readonly share: number | null;
/** Every part that says something, in reading order. */
readonly passages: readonly Passage[];
/**
* Parts that say nothing at all, counted rather than listed.
*
* A spacer, a rule, a stack that only holds other parts. They are not in
* {@link passages} because a passage of no words is not one, and they are
* counted because *a page of two hundred parts, ten of which say anything* is
* a fact about the page worth having and is otherwise invisible here.
*/
readonly silent: number;
/**
* The passages no reader reached, most words first and then in reading order.
*
* **`skipped` only.** A part whose standing is `unknown` reported something
* other than coming into view, or was in a window with no views at all, and
* putting its words here would turn *nothing can be said* into *nobody read
* this* — the one direction a reading of a quiet window must not be wrong in.
* Those words are in {@link wordsByStanding} under `unknown`, where they can
* be seen and not acted on.
*
* By words rather than by depth or position, for the reason a stop is ranked
* by the readers it loses: the passage nobody saw that costs the page
* most is the long one, not the first one.
*/
readonly unseen: readonly Passage[];
/** Every role in the vocabulary, then the row for parts that declared none. */
readonly roles: readonly RoleCopy[];
/**
* Passages whose words are a floor, by the standing of the part saying them.
*
* The direction every share here is wrong in, handed over rather than
* summarised. Undeclared words under `read` understate the numerator and the
* denominator together; under `skipped` they understate the denominator only,
* so a share reads high. One boolean could not have said which.
*/
readonly floored: Readonly<Record<PartStanding, number>>;
/**
* The words the typical reader reached, at most. `null` where {@link silence}
* says why not.
*
* Every word, weighted by the readers who reached the part saying it, over the
* page's views. Generous in both terms and therefore a ceiling: `reached` is
* over-counted by the page views that straddled a rollup window, and
* {@link views} is a floor on the page views there were, so the numerator
* leans up and the denominator leans down.
*
* The straddle very nearly divides out, which is the only reason this is worth
* publishing at all: the same inflation is in every `reached` here and in the
* view floor they are divided by, so what is left is a correction too small to
* be the headline. It is the cancellation a fall between two siblings already
* rests on, taken one level up — a page against its own readers rather
* than a part against its neighbour.
*
* It cannot exceed {@link words}, and `inconsistent` is the input that would
* have made it.
*/
readonly typical: number | null;
/** Why {@link typical} is not there, and `null` where it is. */
readonly silence: CopySilence | null;
};copyReadingOffunction
How much of what a page says is getting read.
const copyReadingOf: (reading: PageReading) => CopyReading
Deliver
signals/deliver
Getting a batch off the page.
DEFAULT_READER_SIGNAL_PATHvalue
Where a delivery goes when the host has no opinion.
const DEFAULT_READER_SIGNAL_PATH = "/api/reader-signals"
SendBeacontype
navigator.sendBeacon, narrowed to what this uses. false means it would not take it.
type SendBeacon = (url: string, body: Blob) => boolean;
PostBatchtype
The fallback. Called only when a beacon was unavailable or refused the batch.
type PostBatch = (url: string, body: string) => void;
DeliverOptionstype
type DeliverOptions = {
/** Where to post. {@link DEFAULT_READER_SIGNAL_PATH} when absent. */
readonly url?: string;
/** The browser's, when absent. A test passes its own. */
readonly beacon?: SendBeacon | null;
/** `fetch` with `keepalive`, when absent. */
readonly post?: PostBatch;
};deliverReaderSignalsfunction
A send for broadcastReaderSignals that posts each batch.
const deliverReaderSignals: (options?: DeliverOptions) => ((batch: ReaderSignalBatch) => void)
Fold
signals/fold
Batches folded into what a node was read like: time on screen, whether it was reached, what readers did in it.
ReaderReadingstype
What a set of batches says about each node they mention.
type ReaderReadings = {
/** Total time on screen. Summed across batches, because `dwelled` is per-batch. */
readonly dwellMs: Readonly<Record<NodeId, number>>;
/** Nodes that came into view at all. `viewed` fires once per node per page view. */
readonly reached: Readonly<Record<NodeId, true>>;
/**
* Times the node itself was used — a link followed, a button pressed, a field
* filled.
*
* It is the node the signal names, which is the nearest addressed element to
* what a reader aimed at. In the starter library that is the control, so a
* band's count here is zero however busy the band was; `engagements` is the
* number that is about the band.
*/
readonly activations: Readonly<Record<NodeId, number>>;
readonly opens: Readonly<Record<NodeId, number>>;
readonly closes: Readonly<Record<NodeId, number>>;
/**
* Times a form inside the node was submitted and the browser let it go.
*
* Filed against the node the signal names, which for the starter library's
* `loom.form` is the form itself. The band it was the end of is in
* `engagements`, like every other delegated kind.
*/
readonly completions: Readonly<Record<NodeId, number>>;
/**
* Times a reader used something *inside* the node — pressed, followed, filled,
* opened or closed, at any depth.
*
* The regions' number. Read off the ancestry a delegated signal carries, so a
* batch whose senders did not walk contributes nothing rather than a guess.
* Strictly inside, so a control's own use is in `activations` and never here,
* and a subtree total is the addition.
*
* Occurrences rather than readers, because a fold is one page view: *how many
* of the people reading this used something in the band* is a question about
* many views, and it is `ReaderTally.engaged`.
*/
readonly engagements: Readonly<Record<NodeId, number>>;
/**
* What each node is, as its signals reported it. Kept because a consumer
* holding only readings would otherwise have to go back to the tree to render
* a row, and the signals already say.
*
* A region named only as somewhere a press happened inside is in here too, so
* it gets a row rather than being a number with no name.
*/
readonly types: Readonly<Record<NodeId, PrimitiveType>>;
readonly batches: number;
readonly signals: number;
};EMPTY_READINGSvalue
const EMPTY_READINGS: ReaderReadings
foldReaderSignalsfunction
One more batch, folded in.
const foldReaderSignals: (readings: ReaderReadings, batch: ReaderSignalBatch) => ReaderReadings
readingsOffunction
const readingsOf: (batches: readonly ReaderSignalBatch[]) => ReaderReadings
NodeReadingtype
One node's line, as something rendering a table wants it.
type NodeReading = {
readonly nodeId: NodeId;
readonly type: PrimitiveType;
readonly dwellMs: number;
readonly reached: boolean;
readonly activations: number;
readonly opens: number;
readonly closes: number;
/** Times a form inside it was submitted and the browser let it go. */
readonly completions: number;
/** Times a reader used something inside it, at any depth. */
readonly engagements: number;
};nodeReadingsOffunction
Readings as rows, longest on screen first.
const nodeReadingsOf: (readings: ReaderReadings) => readonly NodeReading[]
Funnel
signals/funnel
A funnel, read against the readers who arrived.
FunnelStagetype
Where a funnel loses readers, of the two places it can.
type FunnelStage = /** Arrivals that never satisfied the pair's first end. */ "before" /** Arrivals that satisfied the first end and not the second. */ | "between";
FUNNEL_STAGESvalue
const FUNNEL_STAGES: readonly FunnelStage[]
describeFunnelStagefunction
One line per stage, for a surface putting the reading in front of a person.
const describeFunnelStage: (stage: FunnelStage) => string
PairReachtype
One pair of a revision, as stored and as a share of the readers there were.
type PairReach = {
readonly pair: FunnelPair;
/**
* Page views that satisfied `from`, exactly as stored: added across rollup
* windows and therefore generous.
*/
readonly reached: number;
/**
* Page views that satisfied both ends, exactly as stored.
*
* Added across windows like everything else, and **the one counter here that
* a straddle can lose rather than duplicate** — see the module note. Never
* above {@link PairReach.reached}, because every window's answer satisfies
* that and a sum of such answers does too.
*/
readonly converted: number;
/** When the funnel row last moved. */
readonly updatedAt: string;
/**
* `converted ÷ reached` — of the readers who got to the first end, the share
* that finished.
*
* Two counts off one row, so the straddle over-count is in both and very
* nearly divides out. What does *not* divide out is the conversion a straddle
* splits across two windows and loses, so this is a **floor** on the true
* rate wherever {@link FunnelReach.exact} is false.
*
* `null` where nothing reached the first end: the question has no answer
* rather than the answer nought.
*/
readonly rate: number | null;
/**
* The most the true rate could be, given the straddle §8 measured.
*
* A page view that straddled a window could have had its conversion split
* and lost, so the true `converted` is at most `converted + split` where
* `split` is the smaller of the drift and the conversions still missing. The
* same straddle inflates `reached`, so the true `reached` is at least
* `reached − drift`. Both worst cases together, capped at 1.
*
* It closes onto {@link PairReach.rate} where nobody's visit spanned two
* windows, and it opens wide where a deployment's rollup window is short
* relative to how long readers stay — which is the first time the rollup
* window's length can be read off a funnel rather than argued about.
*
* `null` under a silence, where there is no measured drift to bound it with,
* and `null` wherever {@link PairReach.rate} is.
*/
readonly rateAtMost: number | null;
/** Of the readers who arrived, the share that got to the first end. */
readonly entry: FunnelShare;
/** Of the readers who arrived, the share that got to both. */
readonly conversion: FunnelShare;
/**
* The share of arrivals that never reached the first end.
*
* `null` under a silence or where {@link PairReach.unreconciled}. Where it is
* given it is one of three shares that sum to 1.
*/
readonly lostBefore: number | null;
/**
* The share of arrivals that reached the first end and did not convert.
*
* `null` on the same terms as {@link PairReach.lostBefore}.
*/
readonly lostBetween: number | null;
/**
* Which of the two stages cost more readers, by headcount rather than by
* share, which is the ranking rule for a fall between two siblings at its
* two-element case.
*
* `null` where neither is worse: the two losses are equal, which includes the
* funnel that loses nobody. It is *neither stage is the one to look at*
* rather than *there is nothing to look at*, and the two losses are published
* beside it either way.
*/
readonly worse: FunnelStage | null;
/**
* More readers reached the first end than there were page views to reach it
* in, so no share here can be drawn.
*
* Rows written by the same rollups cannot produce this: a view that satisfied
* an end in a window is a view that appeared in it. What can is a funnel row
* older than the page-view column, which is what an upgraded deployment's
* first reading looks like, or a caller who concatenated two reads of the
* counters.
*
* It is not the ordinary straddle — `reached` above the *openings* is
* expected, and is what an {@link FunnelShare.atMost} of 1 means.
*/
readonly unreconciled: boolean;
};FunnelReachtype
A revision's funnels, read against the exact number of readers that revision had.
type FunnelReach = {
readonly treeId: TreeId;
readonly revision: number;
/** Page views that began on this revision. Exact, and addable. */
readonly opened: number;
/** The same page views as the rollups counted them: once per window each appeared in. */
readonly appearances: number;
/** Appearances in excess of openings — the straddle, measured. Never negative. */
readonly drift: number;
/** Openings whose reading no rollup has folded yet. The other sign of the same subtraction. */
readonly pending: number;
/** `drift ÷ opened`: how generous every distinct count here is. `null` when nothing opened. */
readonly inflation: number | null;
/** When the page-view row last moved, or `null` where there is no row. */
readonly updatedAt: string | null;
/** Set where no share can be given, and the reason. `null` where they can. */
readonly silence: ReachSilence | null;
/**
* Nobody's visit spanned two windows, so there is nothing to divide out: the
* rates are exact and each `rateAtMost` equals its `rate`.
*
* False under a silence, and false while any opening is pending.
*/
readonly exact: boolean;
/**
* The revision's pairs, ordered by their two ends so a screen does not
* reorder on a refresh. Deliberately not ranked — see the module note.
*/
readonly pairs: readonly PairReach[];
/** Page-view rows for another tree or revision, and ignored. */
readonly foreign: number;
/** Page-view rows for this revision beyond the first, where the first stands. */
readonly duplicated: number;
/**
* Funnel rows for another tree or revision, and ignored.
*
* Filtered rather than trusted for the same reason the page-view rows are:
* answering one revision's question with another revision's counters is a
* figure nothing downstream could catch.
*/
readonly foreignPairs: number;
/**
* Funnel rows naming a pair already seen, where every one after the first was
* ignored.
*
* A store keeps one row per pair per revision, so this is a caller who
* concatenated two reads. Adding them would double a total that is already
* one.
*/
readonly duplicatedPairs: number;
};funnelReachOffunction
Read a revision's funnels against the readers that revision had.
const funnelReachOf: (where: RevisionOf, funnels: readonly StoredFunnel[], rows: readonly StoredPageViews[]) => FunnelReach
Ingest
signals/ingest
The doorway a browser's batches come through.
MAX_BATCHES_PER_DELIVERYvalue
How many batches one delivery may carry.
const MAX_BATCHES_PER_DELIVERY = 50
IngestOutcometype
type IngestOutcome = {
readonly batches: number;
readonly signals: number;
/**
* Page views this delivery opened, which is what the exact counter moved by.
*
* Zero unless the caller keeps that counter and the delivery actually opened
* something — most deliveries of a page view are the middle of one.
*/
readonly opened: number;
/**
* Page views counted against a region, which is zero unless the caller said
* where the delivery came from and the delivery opened a page view.
*/
readonly regions: number;
/**
* Why a counter did not move, when a store refused it.
*
* Present and the delivery still stands: the batch is already kept, and a
* sender told to retry would deliver it twice, which is the one failure in
* this subsystem that cannot be undone. So a lost count is a counter
* that did not move, reported here rather than raised — the same trade a sink
* makes when a browser refuses a beacon.
*/
readonly openingError?: ReaderSignalStoreError;
readonly regionError?: ReaderSignalStoreError;
};RegionCountertype
Where a delivery came from, and the buckets to count it in.
type RegionCounter = {
readonly store: ReaderRegionStore;
readonly region: ReaderRegion;
};DoorCounterstype
What this delivery is counted into, besides being buffered.
type DoorCounters = {
/** When, as an instant every counter this delivery moves is stamped with. The caller's clock. */
readonly at: string;
/** Where the delivery came from. Absent on a deployment that does not count regions. */
readonly region?: RegionCounter;
/**
* How many page views began. No switch guards it where it is wired, because a
* count of page views of a revision names nowhere and nobody — it is the
* denominator everything else is a rate against.
*/
readonly openings?: ReaderOpeningCounter;
};IngestErrortype
type IngestError = {
readonly code: "invalid-delivery";
/** Which batch of the delivery, so a sender with one bad batch can be told which. */
readonly index: number;
readonly issues: ReaderSignalParseError["issues"];
} | {
readonly code: "too-many";
readonly limit: number;
readonly given: number;
} | ReaderSignalStoreError;describeIngestErrorfunction
const describeIngestError: (error: IngestError) => string
ingestReaderSignalsfunction
Parse a delivery and keep it.
const ingestReaderSignals: (journal: ReaderSignalJournal, input: unknown, counters?: DoorCounters) => Promise<Result<IngestOutcome, IngestError>>
Intake
signals/intake
Who may post a batch, and how often.
CROWDED_SUBJECTvalue
A subject the gate could not make room for.
const CROWDED_SUBJECT = "crowded"
IntakePolicytype
type IntakePolicy = {
/** The largest delivery the door will read, in bytes. */
readonly maxBytes: number;
/** Deliveries one subject may make inside a window. */
readonly deliveries: number;
readonly windowMs: number;
/** How many subjects the counter distinguishes before {@link CROWDED_SUBJECT}. */
readonly subjects: number;
};DEFAULT_INTAKE_POLICYvalue
sendBeacon is specified to refuse a body over 64 KB, so a delivery larger than that did not come from a beacon — which makes the ceiling a statement about the sender rather than a number somebody liked. fetch has no such limit and the fallback path is held to the same one deliberately.
const DEFAULT_INTAKE_POLICY: IntakePolicy
IntakeCounttype
What the gate remembers about one subject: a fixed window and a count inside it. Two numbers and no identity, for AttemptRecord's reason.
type IntakeCount = {
readonly deliveries: number;
readonly windowStartedAt: number;
};IntakeVerdicttype
too-large carries the count as admitted does, because it spent one: a caller whose body was refused has still had their delivery read far enough to refuse it. too-often carries none — the count that refused them is already what there is to remember.
type IntakeVerdict = {
readonly code: "admitted";
readonly count: IntakeCount;
} | {
readonly code: "too-large";
readonly count: IntakeCount;
readonly limit: number;
readonly given: number;
} | {
readonly code: "too-often";
readonly limit: number;
readonly retryAfterMs: number;
};describeIntakeVerdictfunction
const describeIntakeVerdict: (verdict: IntakeVerdict) => string
admitfunction
Whether this delivery is allowed, given what the subject has already sent.
const admit: (count: IntakeCount | null, bytes: number, now: number, policy: IntakePolicy) => IntakeVerdict
IntakeGatetype
type IntakeGate = {
/**
* The policy this gate counts by.
*
* Carried on the gate rather than left beside it, because a caller holding
* both had two copies that could disagree — and the one that decides is
* whichever the gate was built with, silently. A door has one policy.
*/
readonly policy: IntakePolicy;
readonly admit: (subject: string, bytes: number, now: number) => IntakeVerdict;
/** How many subjects are being counted. For a status endpoint, not for a decision. */
readonly subjects: () => number;
};createIntakeGatefunction
The counter that admit decides against.
const createIntakeGate: (policy?: IntakePolicy) => IntakeGate
Journal
signals/journal
Where batches wait to be rolled up.
ReaderSignalStoreErrortype
The only way it fails. No not-found — reading a tree nothing has been received about is an empty page, because a buffer makes no claim that a tree exists. No conflict — receiving a batch cannot collide with another batch.
type ReaderSignalStoreError = {
readonly code: "unavailable";
readonly detail: string;
};describeReaderSignalStoreErrorfunction
const describeReaderSignalStoreError: (error: ReaderSignalStoreError) => string
ReceivedBatchtype
A batch as it comes back out, with the position the buffer gave it.
type ReceivedBatch = ReaderSignalBatch & {
readonly seq: number;
readonly receivedAt: string;
};ReaderSignalReadRequesttype
type ReaderSignalReadRequest = {
/** Absent reads every tree the handle can see, which is the scope rule. */
readonly treeId?: TreeId;
/** A cursor from a previous page's `older`/`newer`, passed back unread. */
readonly cursor?: string;
/** Default `newer`, which with no cursor is the oldest page — where rollup starts. */
readonly direction?: PageDirection;
readonly limit?: number;
};ReaderSignalPagetype
type ReaderSignalPage = PageEnds & {
/** Always ascending by `seq`, whichever end the page was taken from. */
readonly batches: readonly ReceivedBatch[];
};DEFAULT_READER_SIGNAL_LIMITvalue
Larger than a telemetry page, because these are read only to be folded and a batch is small. Rollup walks the whole window, so the page size is purely how much of it is in memory at once.
const DEFAULT_READER_SIGNAL_LIMIT = 500
MAX_READER_SIGNAL_LIMITvalue
const MAX_READER_SIGNAL_LIMIT = 2000
clampReaderSignalLimitfunction
const clampReaderSignalLimit: (limit: number | undefined) => number
ForgetBeforetype
What the buffer is asked to forget, as a position rather than a rule.
type ForgetBefore = {
readonly before: number;
};ForgetOutcometype
type ForgetOutcome = {
readonly removed: number;
};ReaderSignalJournalinterface
Three operations, mirroring the journal's.
interface ReaderSignalJournal {
readonly receive: (batches: readonly ReaderSignalBatch[]) => Promise<Result<void, ReaderSignalStoreError>>;
readonly read: (request?: ReaderSignalReadRequest) => Promise<Result<ReaderSignalPage, ReaderSignalStoreError>>;
readonly forget: (request: ForgetBefore) => Promise<Result<ForgetOutcome, ReaderSignalStoreError>>;
}Kinds
signals/kinds
The five kinds of reader signal, with nothing else in the module.
READER_SIGNAL_KINDSvalue
const READER_SIGNAL_KINDS: readonly ["viewed", "dwelled", "activated", "disclosed", "completed"]
DELEGATED_READER_SIGNAL_KINDSvalue
The kinds whose node is not the thing a reader aimed at.
const DELEGATED_READER_SIGNAL_KINDS: readonly ["activated", "disclosed", "completed"]
Memory
signals/memory
The buffer and the counters, in the process.
memoryReaderSignalJournalfunction
const memoryReaderSignalJournal: (clock?: Clock) => ReaderSignalJournal
memoryReaderTallyStorefunction
const memoryReaderTallyStore: () => ReaderTallyStore
memoryReaderRegionStorefunction
Where readers were, in the process.
const memoryReaderRegionStore: () => ReaderRegionStore
Pace
signals/pace
Whether a part was read or scrolled past.
READING_WORDS_PER_MINUTEvalue
The rate a page's words are costed at, in words per minute.
const READING_WORDS_PER_MINUTE = 240
SKIMMED_BELOWvalue
Below this much of the time its words take, a part was not read.
const SKIMMED_BELOW = 0.5
LINGERED_ABOVEvalue
Above this much, readers stayed longer than the words account for.
const LINGERED_ABOVE = 3
PaceStandingtype
What the time readers spent says about the words a part puts in front of them.
type PaceStanding =
/**
* Readers had less than {@link SKIMMED_BELOW} of the time the words take.
*
* The one verdict here that is safe against every bias in the module
* documentation, and the one a screen should lead with.
*/
"skimmed"
/** Time enough for the words, and not conspicuously more. */
| "paced"
/** More than {@link LINGERED_ABOVE} times the time the words take. */
| "lingered"
/** Nothing can be said, and {@link PacedPart.silence} says which nothing. */
| "unknown";PACE_STANDINGSvalue
const PACE_STANDINGS: readonly PaceStanding[]
PaceSilencetype
Why a part has no pace.
type PaceSilence = /** * No view reported it coming into view, so there is no time to divide and no * reader to divide it between. * * On a window with no views at all, this is every part — which is the same * refusal `parts.ts` makes one level down, arrived at rather than special-cased. */ "unreached" /** * Its words are a floor, and the verdict that floor would support is not the * safe one. * * Some type in its subtree declares no `copy`, or declared a prop holding * something that is not a string. `skimmed` is still reported where it is * reached, because more words only make a part more skimmed; anything else * would be a claim resting on words nobody counted. */ | "unreadable" /** * It says nothing, as far as anything can tell: no words, and nothing * undeclared either. * * A spacer, a rule, an image with no caption. There is no time its words take, * so there is no ratio — and this is kept apart from `unreadable` because * *there are none* and *nobody said* are different answers. */ | "wordless";
PACE_SILENCESvalue
const PACE_SILENCES: readonly PaceSilence[]
describePaceStandingfunction
One line per standing, for a surface putting the reading in front of a person.
const describePaceStanding: (standing: PaceStanding) => string
describePaceSilencefunction
One line per silence, for the same surface.
const describePaceSilence: (silence: PaceSilence) => string
PacedParttype
One part, with the time its readers had against the time its words take.
type PacedPart = {
readonly nodeId: NodeId;
readonly type: PrimitiveType;
/** What part it plays, or `null` where its type declared none. */
readonly role: PrimitiveRole | null;
/** 0 at the root. */
readonly depth: number;
/** null at the root. */
readonly parentId: NodeId | null;
/** Distinct page views that reported it coming into view; `0` where no row named it. */
readonly readers: number;
/** The words this part says itself, counted. Its own, never its subtree's. */
readonly words: number;
/**
* The words of this part and everything under it.
*
* The denominator, because the text on screen while a band was up is the
* band's and its children's. Nests, and is therefore never added across parts.
*/
readonly wordsWithin: number;
/**
* Whether {@link wordsWithin} is a floor rather than a count.
*
* True when anything in the subtree declared no `copy`, or declared a prop
* holding something other than a string.
*/
readonly floored: boolean;
/** Time on screen, summed across every view in the window. */
readonly dwellMs: number;
/**
* The same time per reader, after {@link PaceOptions.inflation}.
*
* `null` where nobody reached it. Generous by the two biases nothing measures:
* dwell counts a part that was merely up, and an ancestor's dwell covers its
* children's reading.
*/
readonly spentMs: number | null;
/** What {@link wordsWithin} takes at the costing rate. `0` where it says nothing. */
readonly needMs: number;
/**
* Time spent for every unit of time the words take. `1` is exactly enough.
*
* `null` where there is nothing to divide: no reader, or no words.
*/
readonly pace: number | null;
readonly standing: PaceStanding;
/** Why there is no verdict, and `null` where there is one. */
readonly silence: PaceSilence | null;
/**
* Readers times the words they were shown here — the ranking key, and a
* magnitude rather than a census.
*
* Nests like {@link wordsWithin} does, so it ranks and is never summed.
*/
readonly wordsPassed: number;
};PagePacetype
type PagePace = {
readonly treeId: TreeId;
readonly revision: number;
/** The page's view floor, carried through from the reading unchanged. */
readonly views: number;
/** Every element part of the revision, in reading order. */
readonly parts: readonly PacedPart[];
readonly standings: Readonly<Record<PaceStanding, number>>;
/** Why the parts with no verdict have none. Counts only the silent ones. */
readonly silences: Readonly<Record<PaceSilence, number>>;
/**
* The root, judged against the whole page.
*
* *Readers spend a third of the time this page's words take* is a true
* sentence and the one a reader screen opens with — and it is kept out of
* {@link mostSkimmed} because the page contains every part, so by words passed
* it would win every ranking it was entered in.
*
* `null` where the revision's root is not an element, which is the one tree
* shape with no part at depth 0.
*/
readonly whole: PacedPart | null;
/**
* The part of the page most words went unread in: most words passed, then
* first in reading order.
*
* By words passed rather than by the lowest pace, for the same reason a stop
* is ranked by the readers it loses: a caption two readers hurried
* past is a worse ratio and a smaller problem than a band four hundred of
* them did.
*/
readonly mostSkimmed: PacedPart | null;
/** The inflation that was applied; `0` when none was given. */
readonly inflation: number;
/** The costing rate that was applied, which is the default unless one was given. */
readonly wordsPerMinute: number;
};PaceOptionstype
type PaceOptions = {
/**
* How generous the reader counts are, as `drift ÷ opened` off the page-view
* rows.
*
* `reached` over-counts by the page views that straddled a rollup window, so
* the mean time per reader is short by the same proportion — and short means
* **more parts called skimmed than should be**, which is the one direction
* this module's safe verdict cannot afford to be wrong in. A deployment that
* has the measurement hands it over and the correction is applied; one that
* does not gets the uncorrected figure and the bias stated here.
*
* Absent, zero, negative or not finite are all *no correction*: a negative
* inflation is not a thing a rollup can produce, and silently trusting one
* would widen the claim rather than narrow it.
*/
readonly inflation?: number;
/**
* The costing rate, where {@link READING_WORDS_PER_MINUTE} is wrong for this
* deployment's text.
*
* Zero, negative or not finite are refused the same way, and the published
* default applies.
*/
readonly wordsPerMinute?: number;
};readingPaceOffunction
Whether readers had time to read what each part of a page says.
const readingPaceOf: (reading: PageReading, options?: PaceOptions) => PagePace
Page views
signals/page-views
How many page views there were — the number every rate on a screen is taken of, and the one number this subsystem could not state.
RevisionViewstype
One revision's worth of page views, as whichever side counted them.
type RevisionViews = {
readonly treeId: TreeId;
readonly revision: number;
readonly views: number;
};ReaderPageViewstype
Page views of one revision of one tree, counted from both ends.
type ReaderPageViews = {
readonly treeId: TreeId;
readonly revision: number;
/**
* Page views that *began* on this revision.
*
* Exact, in the sense that matters: counted once, at the instant a page view
* opened, never recounted, and therefore addable across windows, revisions,
* trees and months. The two things it is not exact against are worth knowing
* — a sender that retries an opening delivery after the first one was kept
* counts twice, and a count cannot be uncounted; and a sender is what
* says it opened at all, so a page that lies inflates this the way it could
* already inflate a region bucket. The rate limiter is what bounds both.
*/
readonly opened: number;
/**
* The same page views as the rollups saw them: once per window each appeared
* in.
*
* Which is why it is kept at all. On its own it is the count a rollup can
* only approximate; beside `opened` it is the measurement of that
* approximation, because the only way the two can differ for an honest sender
* is a page view whose batches landed in more than one window.
*/
readonly appearances: number;
};StoredPageViewstype
A page-view row as the store keeps it, which is the pair plus when it last moved.
type StoredPageViews = ReaderPageViews & {
readonly updatedAt: string;
};openingsOffunction
The page views a delivery opened, per revision, deduplicated by view key.
const openingsOf: (batches: readonly ReaderSignalBatch[]) => readonly RevisionViews[]
RevisionPageViewstype
One revision, as a reading reports it — the stored row, and what subtracting its two numbers says.
type RevisionPageViews = StoredPageViews & {
/**
* Appearances in excess of openings — the over-count in every distinct view
* count stored against this revision, measured rather than bounded.
*
* Never negative: a reading taken between a door write and the next rollup
* sees openings the counters have not met yet, and that is `pending` rather
* than a negative drift.
*/
readonly drift: number;
/**
* Page views that began and whose batches no rollup has folded yet — the
* buffer, as a number.
*
* It is the other sign of the same subtraction, so a revision never has both.
* A reading that showed neither would make a deployment whose collection has
* stopped look like a deployment whose readers had left.
*/
readonly pending: number;
/**
* `drift ÷ opened`: how generous the distinct counts against this revision
* are, as a fraction. `null` when nothing has opened, because the question
* has no answer rather than the answer nought.
*/
readonly inflation: number | null;
};PageViewReadingtype
type PageViewReading = {
/** Page views that began, across every row read. Exact, and addable. */
readonly opened: number;
readonly appearances: number;
readonly drift: number;
readonly pending: number;
readonly inflation: number | null;
/** Newest revision first, trees in id order, so a screen does not reorder on a refresh. */
readonly revisions: readonly RevisionPageViews[];
};pageViewReadingOffunction
Rows from the store, read as something a screen may show.
const pageViewReadingOf: (rows: readonly StoredPageViews[]) => PageViewReading
PageViewsFortype
The page-view row a reading of one revision should be divided by, picked out of a window of rows.
type PageViewsFor = {
/**
* The row, read through {@link pageViewReadingOf}, or `null` where this
* revision has none.
*
* Read through the reading rather than subtracted again, because one
* definition of drift, pending and inflation is the point: a second would be
* a second place for them to disagree with the counter they describe.
*/
readonly measured: RevisionPageViews | null;
/**
* Rows for another tree or another revision, and ignored.
*
* Dividing one revision's counters by another's readers is the mistake that
* would make every rate taken off them quietly wrong.
*/
readonly foreign: number;
/**
* Rows for this tree and revision beyond the first, where every one after it
* was ignored.
*
* A store keeps one row per revision, so this cannot happen from one read —
* it happens when a caller concatenates two. Adding them is wrong, because
* the openings are already a total and would double; taking the last is
* wrong, because it is not the truer one. So the first row stands and the
* fact is reported.
*/
readonly duplicated: number;
};RevisionOftype
A revision of a tree, which is all this rule needs to be asked.
type RevisionOf = {
readonly treeId: TreeId;
readonly revision: number;
};pageViewsForfunction
Pick the page-view row for one revision out of a window of rows.
const pageViewsFor: (where: RevisionOf, rows: readonly StoredPageViews[]) => PageViewsFor
inflationForfunction
The straddle correction for one revision of one page: drift ÷ opened off that page's own door row.
const inflationFor: (where: RevisionOf, rows: readonly StoredPageViews[]) => number | null
Parts
signals/parts
What a counter is about, joined on the server from what the page already knows.
PartStandingtype
What a window of counters says about one part of a page.
type PartStanding = /** At least one view reported it coming into view. */ "read" /** * The window held views of this revision, and none of them said anything * about this part at all. * * It was on the page and in nobody's viewport. The one caveat is in the render * rather than here: a primitive that does not spread its identity attributes * is invisible to the broadcaster and reads as skipped, which is what * `sdk/conformance.ts` exists to catch. */ | "skipped" /** * Nothing can be said. * * Either the window held no views of this revision — so every part is * unknown, and *skipped* would be a statement about readers who were not * there — or the part reported something other than coming into view. The * second is rarer and sharper: a press was delegated to a band that no * `viewed` ever named, so a reader plainly had it in front of them and the * counter that would say so is missing. Calling that skipped would be a lie * in the direction nobody checks. */ | "unknown";
PART_STANDINGSvalue
const PART_STANDINGS: readonly PartStanding[]
describePartStandingfunction
One line per standing, for a surface putting the reading in front of a person.
const describePartStanding: (standing: PartStanding) => string
RoleDeclarationstype
Which types declared each role, as a registry answers it.
type RoleDeclarations = {
readonly typesWithRole: (role: PrimitiveRole) => readonly PrimitiveType[];
};PartDeclarationstype
Everything the join asks of a deployment. A PrimitiveRegistry satisfies it.
type PartDeclarations = CopyDeclarations & RoleDeclarations;
PartReadingtype
One element node of one revision, with whatever the window said about it.
type PartReading = {
readonly nodeId: NodeId;
readonly type: PrimitiveType;
/** What part it plays, or `null` where its type declared none. */
readonly role: PrimitiveRole | null;
/** 0 at the root. */
readonly depth: number;
/** null at the root. */
readonly parentId: NodeId | null;
readonly standing: PartStanding;
/**
* The counters, or `undefined` where no signal in the window named it.
*
* Undefined is not zero and is kept apart from it on purpose. A part with a
* row of zeroes was reported on; a part with no row was not, and the
* difference is the whole of how `skipped` is told from a quiet page.
*/
readonly counters: ReaderTally | undefined;
/**
* The words this part says itself: its declared copy props, and the text
* handed to it directly or through a slot.
*
* **Its own, never its subtree's.** Neither a text node nor a slot node is an
* element, so neither can ever be a part of its own and the words handed into
* one would otherwise be lost; every other descendant is a part in this same
* list. So the words of a page are
* partitioned across its parts exactly once, and a caller may add two rows
* together without reading a headline twice.
*
* `unread` and `unspoken` mean what they mean in the reading they come from
*: nobody has said whether these props are words, and somebody said
* these were words and what is in them is not one. The starter library
* declares `copy` across itself, so `unread` on a page built from it
* is a deployment's own primitives rather than the framework's silence.
*/
readonly copy: NodeCopy;
};RoleReadingtype
Every part playing one role, added up.
type RoleReading = {
/** `null` is the row for parts whose type declared no role, which is most of a page today. */
readonly role: PrimitiveRole | null;
/** Element nodes of this revision playing it. */
readonly parts: number;
readonly standings: Readonly<Record<PartStanding, number>>;
readonly dwellMs: number;
readonly activations: number;
readonly opens: number;
readonly closes: number;
readonly completions: number;
};PageReadingtype
type PageReading = {
readonly treeId: TreeId;
readonly revision: number;
/**
* A floor on the page views the window held, not a count of them.
*
* The largest `views` any single part reports. A view that produced no signal
* about any part is in no row at all, and distinct view counts cannot be added
* across rows, so the largest row is the most that can be said from
* counters alone. It is a good floor in practice because a page root is an
* addressed node and a root is in the viewport of every view that renders.
*
* Zero is what makes every standing `unknown`: nothing was measured, so
* nothing was skipped.
*/
readonly views: number;
/** Every element node, in reading order. */
readonly parts: readonly PartReading[];
/** Every role in the vocabulary, then the row for parts that declared none. */
readonly roles: readonly RoleReading[];
readonly standings: Readonly<Record<PartStanding, number>>;
/**
* Counters handed in that are filed under another tree or another revision,
* and were ignored.
*
* Adding two revisions of a node together is the mistake that makes *before
* versus after a change* unreadable, so this filters rather than trusts its
* caller — and says how much it dropped, because a caller who passed a whole
* store's rows and a caller who passed the wrong revision's look identical
* from the inside.
*/
readonly foreign: number;
/**
* Counters filed under this tree and revision, naming a node this tree does
* not contain.
*
* The alarm for the one failure this join can have that nothing else would
* catch: a tree and a window that do not belong together. Pass revision 7's
* counters with revision 6's tree and most parts read `skipped` while the
* numbers stay plausible. A non-empty list here says the pair is wrong, and
* names the rows that prove it.
*/
readonly orphaned: readonly NodeId[];
/**
* Nodes handed in more than once, where every row but the first was ignored.
*
* A store keeps one row per node per revision, so this cannot happen from one
* read — it happens when a caller concatenates two windows instead of letting
* the store add them. Neither available answer is right: summing is wrong
* because `views`, `reached` and `engaged` count distinct views and
* distinctness cannot be added, and overwriting is wrong because the
* later row is not the truer one. So the first row stands, the rest are
* dropped, and the fact is reported rather than being a quiet halving of
* somebody's dwell time.
*/
readonly duplicated: readonly NodeId[];
};pageReadingOffunction
Join a window's counters to the tree and registry they were filed against.
const pageReadingOf: (tree: LoomTree, tallies: readonly ReaderTally[], declarations: PartDeclarations) => PageReading
wordsReadInfunction
The words readers actually reached, in reading order.
const wordsReadIn: (reading: PageReading) => readonly string[]
Progress
signals/progress
Where in a page reading stops.
ReadingSteptype
One part as a point on its parent's sequence.
type ReadingStep = {
readonly nodeId: NodeId;
readonly type: PrimitiveType;
/** What part it plays, or `null` where its type declared none. */
readonly role: PrimitiveRole | null;
readonly standing: PartStanding;
/**
* Distinct page views that reported it coming into view, and `0` where no row
* named it at all.
*
* Zero means two different things and {@link ReadingStep.anchors} is which:
* nobody got here, or nothing can say.
*/
readonly reached: number;
/**
* Whether this step can be either end of a stop.
*
* False for a part that reported something other than a view, whose `reached`
* of 0 is an absence of evidence rather than an absence of readers.
*/
readonly anchors: boolean;
};ReadingStoptype
Two parts of one run, and the readers who reached the first and not the second.
type ReadingStop = {
/** The part they reached. */
readonly after: NodeId;
/** The next part in the run that could anchor a stop, which fewer reached. */
readonly before: NodeId;
/** Views that reached `after`, which is the denominator of `share`. */
readonly reached: number;
/** Views that reached `after` and not `before`. */
readonly lost: number;
/**
* `lost ÷ reached`, in `(0, 1]`.
*
* Both numbers are `reached` counts off the same rows, so this is the figure
* that survives the over-count the counts themselves carry. It is never
* divided by the page's views or by the page views counted at the door — those
* are a different measurement of a different thing, and dividing across the
* two would put a number on a screen that no two rows agree about.
*/
readonly share: number;
};ReadingRuntype
One parent's children, in the order a reader meets them.
type ReadingRun = {
readonly parentId: NodeId;
/** The depth of the steps, which is one below the parent's. */
readonly depth: number;
readonly steps: readonly ReadingStep[];
/** Every fall, in run order. */
readonly stops: readonly ReadingStop[];
/**
* The last step in run order that any view reached, or `null` where none did.
*
* *How far down they got*, as a node rather than a fraction.
*/
readonly furthest: NodeId | null;
/**
* Pairs where more views reached the later part than the earlier one.
*
* Scrolling cannot do this, so it is a reader who arrived somewhere other than
* the top — an anchored link, a restored scroll position — or a part that is
* on screen whatever the reader does, such as a footer a short page never
* pushes down. Worth seeing and not worth reporting as a negative stop.
*/
readonly gained: number;
};ReadingProgresstype
type ReadingProgress = {
readonly treeId: TreeId;
readonly revision: number;
/**
* The page's view floor, carried through from the reading unchanged.
*
* Zero is the whole answer: nothing was measured, so nothing stopped
* anywhere, and `runs` is empty rather than a page of parts that all look
* skipped.
*/
readonly views: number;
/**
* Every run of two or more children, in reading order.
*
* A run of one is left out: a stop is between two parts, and an only child has
* nowhere for reading to stop. Every part of the page is a step in at most one
* run — its parent's — so no reader's progress is counted at two depths.
*/
readonly runs: readonly ReadingRun[];
/**
* The sharpest fall on the page: most views lost, then largest share, then
* first in reading order.
*
* By views lost rather than by share, because *where does this page lose
* readers* is a question about readers. A band two readers out of three
* abandoned is a worse rate and a smaller problem than one four hundred out of
* a thousand did, and ranking by share would put the former at the top of
* every screen for ever.
*/
readonly steepest: ReadingStop | null;
/**
* Parts that could not anchor a stop, because they reported something other
* than coming into view.
*
* Empty on an ordinary page. A long list is a diagnosis: presses are being
* delegated to regions that no `viewed` names, which is a sender or a
* primitive not reporting what it is, rather than a page nobody read.
*/
readonly unanchored: readonly NodeId[];
/** Every run's {@link ReadingRun.gained}, added. */
readonly gained: number;
};readingProgressOffunction
Where reading stops, from a reading of one revision.
const readingProgressOf: (reading: PageReading) => ReadingProgress
Reach
signals/reach
How far into a page readers get, as a share of the readers there were.
ReachSilencetype
Why a share of readers cannot be given at all.
type ReachSilence = /** * No page-view row for this tree and revision. * * Either the counter has never been written for it — a deployment whose * intake predates the column, or one whose rows have expired — or the caller * read the rows for something else. The counters are still a floor on how * many readers there were, which is what a reading reported before this * join existed, so a surface has something to fall back to and should say * which it is showing. */ "unmeasured" /** * The row is there and nothing in it says a page view ever began. * * Two deployments look like this and the second is the one worth catching. * If no rollup has counted an appearance either, nobody has read this * revision and there is nothing to divide. If appearances have been counted, * **the senders are not marking their openings** — a page running a * broadcaster from before the marker shipped, or one whose opening delivery * is being lost — and every rate on the screen has no denominator while the * counters look healthy. That is a diagnosis nothing else in this subsystem * offers. */ | "unopened" /** * Readers arrived and no rollup has folded a window yet. * * Everything is `pending`. It is what the first minutes of a deployment look * like, and what a stalled collection looks like for ever, and the two are * told apart by whether the number moves. */ | "uncounted";
REACH_SILENCESvalue
const REACH_SILENCES: readonly ReachSilence[]
describeReachSilencefunction
One line per silence, for a surface putting the reading in front of a person.
const describeReachSilence: (silence: ReachSilence) => string
PartReachtype
How far one part of a page got, against the readers there were.
type PartReach = {
readonly nodeId: NodeId;
readonly type: PrimitiveType;
/** What part it plays, or `null` where its type declared none. */
readonly role: PrimitiveRole | null;
/** 0 at the root, as the reading's outline gives it. */
readonly depth: number;
readonly standing: PartStanding;
/**
* Distinct page views that saw it, exactly as stored: added across rollup
* windows and therefore generous.
*
* Published beside the shares rather than hidden behind them, because it is
* the only figure here that is not a division and the one to fall back to
* when the pair cannot be reconciled.
*/
readonly reached: number;
/**
* The share of page views that got this far, as the rollups counted both
* ends: `reached ÷ appearances`.
*
* The straddle over-count is in the numerator and the denominator and very
* nearly divides out, which is what makes this the figure to show. It is an
* estimate and not a bound, and the direction it leans is worth knowing: a
* reader who stayed long enough to span two windows is counted twice on both
* sides, and a reader who stays longer is likelier to have got deep into the
* page — so for a part near the bottom this reads a little generous.
*
* **Its denominator is the page views that have been counted, which is not
* every reader who arrived.** While openings are pending the two are
* different populations, so this can sit above
* {@link PartReach.atMost} — that is not a contradiction and the pending
* figure is the explanation.
*
* `null` under a silence.
*/
readonly share: number | null;
/**
* The most the share could be: `reached ÷ opened`, never above 1.
*
* A ceiling rather than an estimate. The openings are a count of readers that
* no window boundary can inflate, and `reached` is at least the number of
* readers who really got here, so the true share is at or below this.
*
* **For a part nearly everybody reaches it is 1 and says nothing**, and that
* is the ordinary state rather than a fault: a root is in the viewport of
* every page view, so where any visit spanned two windows its `reached` is
* above the openings on its own. The estimate is the figure with information
* in it there; this is the one that bounds a part further down.
*
* `null` under a silence.
*/
readonly atMost: number | null;
/**
* The share applied to the exact count of readers who arrived — the number a
* sentence can say out loud.
*
* Deliberately not rounded: it is a magnitude and not a census, and a surface
* that rounds it is saying how precise it thinks it is.
*
* **One division of two whole numbers, not the share multiplied back up.**
* The two are the same arithmetic in exact numbers and not in a float —
* seven readers out of a hundred, times a hundred, is seven and a
* quadrillionth, which is above the most readers who could have got there and
* would make this figure break its own ceiling.
*
* Withheld in three states rather than given as a number nobody could stand
* behind: under a silence; whenever the collection is behind, because
* openings a rollup has not reached yet are readers whose reading nobody has
* counted and projecting the folded rate onto them would answer a question
* about people who were never measured; and where the two counters cannot be
* reconciled, because a count of people above the number of people who
* arrived is the one figure a screen must never show.
*/
readonly readers: number | null;
/**
* The most readers who could have got here: the smaller of `reached` and the
* openings, which is a whole number of real page views either way.
*
* `null` under a silence. Where {@link PartReach.readers} is given it is
* never above this.
*/
readonly atMostReaders: number | null;
/**
* More reach than there were page views to be reached in, so the share sits
* above 1 and no figure here can be drawn.
*
* Reach is counted out of the same windows as the appearances it is divided
* by, and a view that reached a part in a window is a view that appeared in
* it — so rows written by the same rollups cannot produce this. Two things
* can. **Node counters older than the page-view column**, added up over
* windows whose appearances were never kept, which is what an upgraded
* deployment's first reading looks like. Or a caller who concatenated two
* reads of the counters instead of letting the store add them, which the
* reading upstream reports as a duplicated node.
*
* It is not the ordinary straddle: `reached` above the *openings* is
* expected, and is what {@link PartReach.atMost} being 1 means.
*
* A surface should show `reached` and say it cannot yet be put as a share.
*/
readonly unreconciled: boolean;
};PageReachtype
A window of counters, read against the exact number of readers that window had.
type PageReach = {
readonly treeId: TreeId;
readonly revision: number;
/** Page views that began on this revision. Exact, and addable. */
readonly opened: number;
/** The same page views as the rollups counted them: once per window each appeared in. */
readonly appearances: number;
/** Appearances in excess of openings — the straddle, measured. Never negative. */
readonly drift: number;
/** Openings whose reading no rollup has folded yet. The other sign of the same subtraction. */
readonly pending: number;
/** `drift ÷ opened`: how generous every distinct count here is. `null` when nothing opened. */
readonly inflation: number | null;
/** When the page-view row last moved, or `null` where there is no row. */
readonly updatedAt: string | null;
/**
* The floor the counters give on their own: the largest `views` any one row
* reports.
*
* What a reading had to use as a denominator before there was an exact one,
* kept so that a deployment can see the difference the join made rather than
* being told it was wrong. Against an honest sender it sits at or below the
* openings plus the straddle.
*/
readonly countedViews: number;
/**
* Set where no share can be given, and the reason. `null` where they can.
*/
readonly silence: ReachSilence | null;
/**
* Nobody's visit spanned two windows, so there is nothing to divide out: the
* estimate and the ceiling are the same number and the shares are exact.
*
* False under a silence, and false while any opening is pending.
*/
readonly exact: boolean;
/** Every part of the revision, in reading order, as the reading had them. */
readonly parts: readonly PartReach[];
/**
* The parts whose reach could not be reconciled with the openings.
*
* Empty is the healthy state. A non-empty list on a deployment whose drift is
* nought is not a straddle and not a lie: it is node counters older than the
* column they are being divided by.
*/
readonly unreconciled: readonly NodeId[];
/**
* Page-view rows handed in for another tree or another revision, and ignored.
*
* Dividing one revision's counters by another's readers is the mistake that
* would make every rate here quietly wrong, so this filters rather than
* trusts its caller — and says how much it dropped, because a caller who
* passed a whole store's rows and one who passed the wrong revision's look
* identical from the inside.
*/
readonly foreign: number;
/**
* Rows for this tree and revision beyond the first, where every one after it
* was ignored.
*
* A store keeps one row per revision, so this cannot happen from one read —
* it happens when a caller concatenates two reads. Adding them is wrong,
* because the openings are already a total and would double; taking the last
* is wrong, because it is not the truer one. So the first row stands and the
* fact is reported.
*/
readonly duplicated: number;
};pageReachOffunction
Read a window's counters against the readers that window had.
const pageReachOf: (reading: PageReading, rows: readonly StoredPageViews[]) => PageReach
Readable
signals/readable
What *on screen* means, as the two numbers that decide it.
READABLE_VISIBLE_FRACTIONvalue
At least this much of the element is visible.
const READABLE_VISIBLE_FRACTION = 0.5
READABLE_VIEWPORT_FRACTIONvalue
Or the element fills at least this much of the window.
const READABLE_VIEWPORT_FRACTION = 0.3
Region
signals/region
Where readers are, as a counter and nothing else.
UNKNOWN_REGIONvalue
A region nobody can name.
const UNKNOWN_REGION: ReaderRegion
regionPatternvalue
Two uppercase letters, which is what a country code is.
const regionPattern: RegExp
readerRegionSchemaschema
A country, or the admission that there is none.
const readerRegionSchema: z.ZodBranded<…>
ReaderRegiontype
type ReaderRegion = z.infer<typeof readerRegionSchema>;
readerRegionOffunction
Read a region off whatever the platform wrote, and never fail.
const readerRegionOf: (given: string | null | undefined) => ReaderRegion
ReaderRegionCounttype
Page views from one region, of one revision of one tree.
type ReaderRegionCount = {
readonly treeId: TreeId;
readonly revision: number;
readonly region: ReaderRegion;
/** Page views that *began* here, so a longer visit is not a bigger number. */
readonly views: number;
};StoredRegionCounttype
A region count as the store keeps it, which is the count plus when it last moved.
type StoredRegionCount = ReaderRegionCount & {
readonly updatedAt: string;
};ReaderRegionStoreinterface
The durable half, and the only one there is — a region has no raw form to expire, because it was never written down as an observation.
interface ReaderRegionStore {
/**
* Add views to the buckets they belong in, creating rows that do not exist
* yet. Additive for the same reason applying a rollup is: every call reports
* what just arrived rather than what is true so far.
*/
readonly count: (counts: readonly ReaderRegionCount[], at: string) => Promise<Result<void, ReaderSignalStoreError>>;
readonly regions: (request?: TallyReadRequest) => Promise<Result<readonly StoredRegionCount[], ReaderSignalStoreError>>;
}regionCountsOffunction
What one delivery adds, which is one view per page view it opened.
const regionCountsOf: (openings: readonly RevisionViews[], region: ReaderRegion) => readonly ReaderRegionCount[]
DEFAULT_REGION_FLOORvalue
The smallest a bucket may be before it is named.
const DEFAULT_REGION_FLOOR = 25
regionFloorOffunction
The floor that will actually be used, given what somebody asked for.
const regionFloorOf: (asked: number | undefined) => number
RegionBuckettype
One region, as a reading reports it.
type RegionBucket = {
readonly region: ReaderRegion;
readonly views: number;
};RegionReadingtype
type RegionReading = {
/** The floor the rows were read at, so a screen can say what it is withholding by. */
readonly floor: number;
/** Buckets large enough to name, most-read first. */
readonly regions: readonly RegionBucket[];
/** What was too small to name, as a total that names nowhere. */
readonly withheld: {
readonly buckets: number;
readonly views: number;
};
/** Every view in the rows, named or withheld. A reading never loses one. */
readonly views: number;
};RegionReadingOptionstype
type RegionReadingOptions = {
/** Raised above {@link DEFAULT_REGION_FLOOR}; a smaller number is the default. */
readonly floor?: number;
};regionReadingOffunction
Rows from the store, read as something a screen may show.
const regionReadingOf: (rows: readonly StoredRegionCount[], options?: RegionReadingOptions) => RegionReading
Rollup
signals/rollup
Many views, folded into the durable artefact: per-node-per-revision counters, and the funnel pairs a deployment asked about.
ReaderTallytype
One node at one revision, as every view of it added up.
type ReaderTally = {
readonly treeId: TreeId;
readonly revision: number;
readonly nodeId: NodeId;
readonly type: PrimitiveType;
/** Distinct page views that produced any signal at all about this node. */
readonly views: number;
/** Distinct page views in which it came into view. The denominator of a funnel. */
readonly reached: number;
/**
* Distinct page views in which a reader used something *inside* this node —
* pressed a link or button, filled a field, opened or closed a disclosure.
*
* The counter regions are reported by, and the only one that needs a signal to
* say more than which node it is about. A press is filed against the control,
* and every control in the starter library is an addressed node of its own, so
* `activations` on a band is nearly always zero and always will be: the band
* is not the thing anybody pressed. This counts the views whose reader did
* something under it, at any depth, from the ancestry a delegated signal
* carries.
*
* **Strictly inside.** A button's own `engaged` is 0 while its `activations`
* is 1; its band's is the other way round. The two never double-count the same
* node, and a subtree total is the addition.
*
* **Zero from a sender that does not walk.** A batch whose delegated signals
* carry no `within` — synthesised on a server, replayed from a fixture, sent
* by a host that turned the walk off — adds nothing here, exactly as a batch
* with no view key adds nothing to `views`.
*/
readonly engaged: number;
readonly dwellMs: number;
readonly activations: number;
readonly opens: number;
readonly closes: number;
/**
* Times a form inside this node was submitted and the browser let it go.
*
* The conversion counter, and the only one that is about a reader finishing
* rather than a reader looking. It is an occurrence rather than a view count
* for the same reason `activations` is: a reader who submits twice did two
* things, and *how many views converted* is a `FunnelPair` ending in
* `completed`, which is one row and already answerable.
*
* Filed against the node the form is — `loom.form` is addressed, so usually
* the form itself — never the band above it. The band's number is `engaged`,
* credited from the ancestry the signal carries.
*/
readonly completions: number;
};FunnelEndtype
One end of a funnel: a node, and what a reader has to have done to it.
type FunnelEnd = {
readonly nodeId: NodeId;
readonly kind: ReaderSignalKind;
};FunnelPairtype
A question a deployment names, phrased about the tree rather than about anyone: *of the views that did X to node A, how many did Y to node B*.
type FunnelPair = {
readonly from: FunnelEnd;
readonly to: FunnelEnd;
};FunnelAnswertype
type FunnelAnswer = {
readonly treeId: TreeId;
readonly revision: number;
readonly pair: FunnelPair;
/** Views that satisfied `from`. */
readonly reached: number;
/** Views that satisfied both. Never greater than `reached`. */
readonly converted: number;
};Rolluptype
type Rollup = {
readonly tallies: readonly ReaderTally[];
readonly funnels: readonly FunnelAnswer[];
/** Distinct page views seen, across every tree and revision in the input. */
readonly views: number;
/**
* The same page views, per tree and revision — one appearance each, in this
* window.
*
* Held apart from `views` because it is the number the durable page-view
* counter keeps, and it is only worth keeping beside the exact count of page
* views that *began*: the difference between the two is the straddle that
* makes a distinct count added across windows generous, measured rather than
* bounded. A revision whose batches carried no view key is absent
* rather than nought — there is nothing to compare, and a zero row would read
* as *no readers* rather than as *nobody minted a key*.
*/
readonly appearances: readonly RevisionViews[];
/**
* Batches that carried no view key.
*
* Their occurrences are counted like any other; their views are not, because
* there is no honest number to add. Treating each uncorrelated batch as its
* own view would inflate every denominator by however often a page happened
* to flush, which is a number about the network rather than about readers.
* Reported so a portal can say the rate is computed over fewer views than the
* totals, instead of showing a rate that is quietly wrong.
*/
readonly uncorrelated: number;
};EMPTY_ROLLUPvalue
const EMPTY_ROLLUP: Rollup
RollupOptionstype
type RollupOptions = {
/**
* The funnels this deployment asked for. Absent means none: a pair is a
* question somebody wrote down, and a rollup does not invent questions.
*/
readonly pairs?: readonly FunnelPair[];
};rollUpfunction
Fold a window of stored batches into the counters that outlive them.
const rollUp: (batches: readonly ReaderSignalBatch[], options?: RollupOptions) => Rollup
Signal
signals/signal
What a published page may say about how it is being read.
readerSignalKindSchemaschema
const readerSignalKindSchema: z.ZodEnum<…>
ReaderSignalKindtype
type ReaderSignalKind = z.infer<typeof readerSignalKindSchema>;
viewKeySchemaschema
The key that says two batches came from the same page view, and says nothing else.
const viewKeySchema: z.ZodBranded<…>
ViewKeytype
type ViewKey = z.infer<typeof viewKeySchema>;
signalAddressSchemaschema
One addressed node, as a signal names it.
const signalAddressSchema: z.ZodObject<…>
readerSignalSchemaschema
The five kinds.
const readerSignalSchema: z.ZodDiscriminatedUnion<…>
ReaderSignaltype
type ReaderSignal = z.infer<typeof readerSignalSchema>;
readerSignalBatchSchemaschema
One delivery: the signals a page gathered since the last one, all about the same tree at the same revision.
const readerSignalBatchSchema: z.ZodEffects<…>
ReaderSignalBatchtype
type ReaderSignalBatch = z.infer<typeof readerSignalBatchSchema>;
ReaderSignalParseErrortype
type ReaderSignalParseError = {
readonly code: "invalid-batch";
readonly issues: readonly {
readonly path: string;
readonly message: string;
}[];
};parseReaderSignalBatchfunction
Read a batch that arrived from somewhere untrusted — a request body, a queue.
Shown in use on What your readers do — Reading a batch back
const parseReaderSignalBatch: (input: unknown) => Result<ReaderSignalBatch, ReaderSignalParseError>
Tally
signals/tally
Where the counters live once the batches they came from are gone.
StoredTallytype
One node's line as the store keeps it, which is a tally plus when it last changed.
type StoredTally = ReaderTally & {
readonly updatedAt: string;
};StoredFunneltype
One funnel answer as the store keeps it.
type StoredFunnel = FunnelAnswer & {
readonly updatedAt: string;
};TallyReadRequesttype
type TallyReadRequest = {
/** Absent reads every tree the handle can see, which is the scope rule. */
readonly treeId?: TreeId;
/**
* Absent reads every revision.
*
* Almost every question the portal asks is about one revision or about two
* being compared, and *before versus after a change* is the second of those —
* so this is a filter rather than a requirement, and a caller comparing
* revisions reads them all and groups.
*/
readonly revision?: number;
};ReaderOpeningCounterinterface
The one write the door makes into the durable counters.
interface ReaderOpeningCounter {
/**
* Add the page views a delivery opened, creating rows that do not exist yet.
*
* Additive like everything else in this file, and for the sharpest reason of
* any of them: every call is one delivery's worth of arrivals, so a store
* that replaced would report the last reader rather than the readership.
*/
readonly opened: (openings: readonly RevisionViews[], at: string) => Promise<Result<void, ReaderSignalStoreError>>;
}ReaderTallyStoreinterface
Counters are not paged.
interface ReaderTallyStore extends ReaderOpeningCounter {
/**
* Add a rollup's counters to what is already there, creating rows that do not
* exist yet. Atomic over the whole rollup where the backend can be: a partly
* applied window is counters that disagree with each other.
*/
readonly apply: (rollup: {
readonly tallies: readonly ReaderTally[];
readonly funnels: readonly FunnelAnswer[];
/**
* The window's page views, per revision, for the counter the exact count
* is compared against.
*
* Optional, and absent is not none: a caller applying counters it
* composed itself — a backfill, a fixture, a test — has no window to
* report, and a nought written there would read as *no page views* and
* make a reading claim the node counters were exact.
*/
readonly appearances?: readonly RevisionViews[];
}, at: string) => Promise<Result<void, ReaderSignalStoreError>>;
readonly tallies: (request?: TallyReadRequest) => Promise<Result<readonly StoredTally[], ReaderSignalStoreError>>;
readonly funnels: (request?: TallyReadRequest) => Promise<Result<readonly StoredFunnel[], ReaderSignalStoreError>>;
/**
* How many page views there were, which is the denominator every rate on a
* screen needs and the only row here written from two sides.
*/
readonly pageViews: (request?: TallyReadRequest) => Promise<Result<readonly StoredPageViews[], ReaderSignalStoreError>>;
}View
signals/view
The one value in the reader-signal seam that correlates anything.
VIEW_KEY_BYTESvalue
128 bits, the same width a UUID spends on randomness.
const VIEW_KEY_BYTES = 16
VIEW_KEY_LENGTHvalue
const VIEW_KEY_LENGTH: number
viewKeyPatternvalue
const viewKeyPattern: RegExp
RandomBytestype
Where the randomness comes from. A parameter so a test can mint a key it can name, and for no other reason — a host has no business supplying this.
type RandomBytes = (count: number) => Uint8Array;
mintViewKeyfunction
A fresh view key. Called once per broadcast, never per batch — the whole point is that the batches of one page view agree.
const mintViewKey: (random?: RandomBytes) => ViewKey