•19 min read

Consent-Free Visitor Flows: Reconstructing Multi-Step Navigation via Same-Origin Referrers and First-Order Markov Chains in Plausible

Consent-free visitor flow reconstruction is a stateless web telemetry architecture that captures multi-step internal navigation paths without cookies, localStorage, sessionStorage, or persistent session identifiers. By combining ephemeral same-origin document.referrer step transitions (prev_page) with first-order Markov chain models and Hamilton Largest Remainder apportionment, engineering teams achieve 100% ePrivacy-compliant path analytics without consent banners.

Architectural DimensionCookie / LocalStorage Session Tracking (GA4)Client Ephemeral flow_id (sessionStorage / window.name)Stateless Same-Origin document.referrer (prev_page) + 1st-Order Markov Flows (hackenberg.tech)
Terminal Storage Write (Art. 5(3) ePrivacy)Writes persistent _ga cookies or localStorage tokens to user terminal equipmentWrites tab-scoped sessionStorage or window.name state into browser memoryZero client storage writes; reads standard transient HTTP navigation metadata (document.referrer)
Consent Banner RequirementMandatory prior opt-in consent modal required across EU/EEA jurisdictionsLegally contested under EDPB Guidelines 2/2023 due to terminal storage write & read100% consent-free; no privacy modal required, eliminating banner fatigue and UI clutter
Telemetry Coverage RateTypically 35%–60% (severe sampling bias from consent rejections and adblockers)40%–65% if gated behind opt-in; risks regulatory non-compliance if ungated100% server-observed traffic coverage (respecting voluntary browser GPC and DNT signals)
Cross-Page Path ReconstructionDeterministic multi-hop sequence joined via persistent client session_idDeterministic tab-level sequence joined via generated flow_id UUIDDual-Engine: Empirical 1-step prev_page transitions + 1st-order Markov chain boundary synthesis
Flow Conservation InvariantNative per-session event stream aggregationNative per-tab sequence aggregationEnforced deterministically via Largest Remainder Method (Hamilton apportionment)

In our earlier architectural deep dive on the Unified Analytics MCP Server for SEO, GEO, and AIO audits and our subsequent Agentic SEO, GEO, and AIO case study, we demonstrated how autonomous coding agents combine Google Search Console search intent with Plausible Analytics engagement metrics. However, reconstructing multi-step visitor flows—understanding exactly which upstream catalog or article led a reader to a target page, which high-intent actions they triggered on the page, and which internal page they navigated to next—traditionally created a painful architectural dilemma between regulatory compliance and analytical fidelity.

This article presents the mathematical and software engineering architecture behind our privacy-by-design refactoring in src/commons/client/telemetry.ts and src/commons/server/analytics/clients/plausible.ts. We examine why we purged every trace of client-side flow_id generation and deleted our privacy consent modal, how we capture empirical first-order step transitions via canonicalized document.referrer properties, and how our server-side analytics client bridges empirical step data and historical aggregates using first-order Markov chains [Sar00] and integer apportionment [BY82].

1. Why Do Ephemeral sessionStorage and window.name Identifiers Trigger ePrivacy Art. 5(3)?

Under Article 5(3) of the EU ePrivacy Directive and EDPB Guidelines 2/2023, storing or gaining access to information already stored in a subscriber’s terminal equipment requires prior informed consent unless strictly necessary for service delivery. Even ephemeral, tab-scoped identifiers in sessionStorage or window.name constitute regulated terminal equipment storage operations.

Many well-intentioned engineering teams assume that replacing persistent third-party cookies with short-lived random tokens stored in window.sessionStorage or window.name exempts their analytics pipeline from consent banners. In an earlier iteration of our own telemetry layer, we experimented with generating an ephemeral, tab-scoped flow_id token (gh_flow:<random>) coupled with a step counter (step) in sessionStorage to stitch multi-hop trajectories across page loads. Because the identifier was wiped as soon as the browser tab closed and never cross-referenced personal data, it felt privacy-friendly.

Yet a rigorous legal and technical audit against the European Data Protection Board (EDPB) Guidelines 2/2023 on the Technical Scope of Art. 5(3) of the ePrivacy Directive [Edp24] reveals why any client-stored flow token is a structural trap. Crucially, engineers must distinguish between the General Data Protection Regulation (GDPR, Regulation (EU) 2016/679), which governs the processing of personal data, and Article 5(3) of the ePrivacy Directive (2002/58/EC), which operates as lex specialis protecting the private sphere and integrity of the user’s terminal equipment itself. Under Article 5(3), the consent requirement is triggered the moment non-essential information is stored on or read back from the client device—regardless of whether an ephemeral token like gh_flow:<random> qualifies as personal data under the GDPR:

  1. The Storage Medium Is Irrelevant (Art. 5(3) Neutrality): Article 5(3) of Directive 2002/58/EC is technologically neutral. It does not regulate “cookies” specifically; it regulates the storing of information, or the gaining of access to information already stored, in the terminal equipment of a subscriber or user. Writing a 16-byte string to window.sessionStorage, window.localStorage, IndexedDB, or window.name inside the browser’s RAM/DOM state is legally indistinguishable from setting an HTTP cookie when used for non-essential analytics.
  2. Ephemerality Does Not Override the “Strictly Necessary” Threshold: Under the four cumulative applicability criteria (Criteria A–D: information, terminal equipment, public communications network, and storage or gaining access) formalized by the EDPB [Edp24], any instruction transmitted to the client browser that instructs it to store a state value (sessionStorage.setItem('flow_id', ...)) and subsequently read it back on the next pageview (sessionStorage.getItem('flow_id')) falls squarely within Art. 5(3). Unless that state is strictly necessary to provide an information society service explicitly requested by the user (such as a shopping cart or authentication session), it requires prior opt-in consent.
  3. The Analytics Sampling Catastrophe: Maintaining an opt-in consent modal on a technical engineering publication destroys both user experience and statistical validity. When 50% to 70% of privacy-conscious developers dismiss or block a consent banner, the remaining 30% of tracked sessions exhibit severe selection bias. Worse, any visitor who navigates across two pages before interacting with the consent modal creates a fractured, unlinked trajectory.

By eliminating flow_id, step, sessionStorage writes, and window.name state persistence altogether, we achieved a clean architectural invariant: our telemetry runtime writes zero bytes of tracking state to the visitor’s terminal equipment. As a consequence, we were able to completely delete the PrivacyModal component and its associated client-side consent state machine from our Astro layout tree.

Instead of gating telemetry behind a modal banner, our initialization routine in src/commons/client/telemetry.ts respects voluntary browser-level privacy signals (navigator.globalPrivacyControl and navigator.doNotTrack) and actively scrubs any legacy window.name flow prefixes left over from older browser sessions:

// Excerpt from src/commons/client/telemetry.ts
export function initAnalytics(): void {
  if (typeof window === 'undefined' || isAnalyticsInitialized) return;
  isAnalyticsInitialized = true;

  // Respect Global Privacy Control (GPC) and Do-Not-Track (DNT)
  try {
    if (
      navigator.globalPrivacyControl === true ||
      navigator.doNotTrack === '1' ||
      (window as Window & { doNotTrack?: string }).doNotTrack === '1'
    ) {
      localStorage.setItem('plausible_ignore', 'true');
    }
  } catch {
    // Ignore localStorage access restrictions
  }

  try {
    if (typeof window.name === 'string' && window.name.startsWith('gh_flow:')) {
      window.name = '';
    }
  } catch {
    // Ignore window.name access restrictions
  }

  try {
    initPlausible({
      domain: 'hackenberg.tech',
      endpoint: 'https://analytics.mentawise.com/api/event',
      outboundLinks: true,
      fileDownloads: true,
      bindToWindow: true,
      customProperties: () => ({
        prev_page: getPreviousInternalPage(),
      }),
    });
  } catch (err) {
    console.debug('[Telemetry] Failed to initialize Plausible tracker:', err);
  }
}

Note why localStorage.setItem('plausible_ignore', 'true') on line 128 is fully compliant with Article 5(3): this flag is never written during normal visitor sessions and contains zero tracking identifiers. It is set exclusively when the visitor’s browser actively broadcasts an explicit Global Privacy Control (GPC) or Do-Not-Track (DNT) opt-out signal, is never transmitted over the network, and is strictly necessary to execute the user’s explicit browser-level instruction to suppress telemetry.

2. Stateless Single-Step Edge Capture via Same-Origin document.referrer

Stateless single-step edge capture extracts the immediate upstream internal page path from the browser’s native document.referrer API during page initialization. By normalizing same-origin URLs into canonical pathnames and mapping external referrers, direct entries, and page reloads to '(entry)', every Plausible event records its exact first-order transition edge without client storage.

When a user clicks an internal link on a standard Multi-Page Application (MPA) website (such as Astro’s static site generation on hackenberg.tech, where each route transition triggers a full browser document navigation), the browser navigates from the source URL ut−1u_{t-1} to the destination URL utu_t. Under the W3C Referrer Policy specification [ES17] (where modern browsers enforce strict-origin-when-cross-origin by default), the browser automatically populates document.referrer with the full URL of ut−1u_{t-1} for all same-origin navigations over HTTPS. (By contrast, Single-Page Application (SPA) routers that mutate URLs via history.pushState without reloading the document do not update document.referrer natively and would require an in-memory route transition listener.)

Crucially, document.referrer is an intrinsic, transient property of the HTTP/HTML navigation lifecycle itself—not an application-controlled storage slot written into terminal equipment. By inspecting document.referrer at event dispatch time inside src/commons/client/telemetry.ts, we can deterministically classify the immediate predecessor state St−1S_{t-1} of any pageview on state St=utS_t = u_t into one of two disjoint categories:

  1. Boundary Entry State ((entry)): If document.referrer is empty (direct URL entry, bookmark, or noreferrer link), if its origin refUrl.origin differs from window.location.origin (external search engine or social referral), or if its normalized pathname equals the current pathname refNorm === curNorm (same-page reload or hash jump), the transition originated outside the internal state graph or represents a self-loop reload. We map all such cases to the canonical boundary symbol '(entry)'.
  2. Internal Upstream State (refNorm): If refUrl.origin === window.location.origin and refNorm !== curNorm, the visitor executed a genuine internal state transition St−1→StS_{t-1} \to S_t. We strip query parameters (?) and fragment identifiers (#), enforce leading and trailing slashes, and return the canonical internal pathname (e.g., '/posts/' or '/services/').
// Excerpt from src/commons/client/telemetry.ts
function normalizeInternalPathname(rawPathname: string): string {
  const clean = (rawPathname || '/').split('?')[0].split('#')[0].trim() || '/';
  const withLeading = clean.startsWith('/') ? clean : `/${clean}`;
  if (withLeading === '/') return '/';
  return withLeading.endsWith('/') ? withLeading : `${withLeading}/`;
}

export function getPreviousInternalPage(): string {
  if (typeof window === 'undefined' || typeof document === 'undefined' || !document.referrer) {
    return '(entry)';
  }
  try {
    const refUrl = new URL(document.referrer);
    if (refUrl.origin !== window.location.origin) {
      return '(entry)';
    }
    const refNorm = normalizeInternalPathname(refUrl.pathname || '/');
    const curNorm = normalizeInternalPathname(window.location.pathname || '/');
    if (refNorm === curNorm) {
      return '(entry)';
    }
    return refNorm;
  } catch {
    return '(entry)';
  }
}

By passing customProperties: () => ({ prev_page: getPreviousInternalPage() }) to initPlausible from @plausible-analytics/tracker (which injects props.prev_page into the request payload of every automatic pageview, Outbound Link: Click, File Download, and custom trackEvent call), every event stored in Plausible [Plau24] carries a stateless first-order link to its immediate predecessor.

plausible.tsPlausible APItelemetry.tsBrowser TabNavigate from /posts/ to /posts/slug/1getPreviousInternalPage() reads document.referrer2POST /api/event (page="/posts/slug/", prev_page="/posts/")3Query breakdown(event:props:prev_page, event:page=="/posts/slug/")4Query breakdown(event:page, event:props:prev_page=="/posts/slug/")5Stateless Client-to-Server Edge Capture Lifecycle
plausible.tsPlausible APItelemetry.tsBrowser TabNavigate from /posts/ to /posts/slug/1getPreviousInternalPage() reads document.referrer2POST /api/event (page="/posts/slug/", prev_page="/posts/")3Query breakdown(event:props:prev_page, event:page=="/posts/slug/")4Query breakdown(event:page, event:props:prev_page=="/posts/slug/")5Stateless Client-to-Server Edge Capture Lifecycle
Sequence diagram illustrating how same-origin document.referrer is normalized into prev_page without writing to browser storage.
View Diagram Source
---
title: "Stateless Client-to-Server Edge Capture Lifecycle"
caption: "Sequence diagram illustrating how same-origin document.referrer is normalized into prev_page without writing to browser storage."
---
sequenceDiagram
  autonumber
  actor Visitor as Browser Tab
  participant Telemetry as telemetry.ts
  participant Plausible as Plausible API
  participant Engine as plausible.ts

  Visitor->>Telemetry: Navigate from /posts/ to /posts/slug/
  Telemetry->>Telemetry: getPreviousInternalPage() reads document.referrer
  Telemetry->>Plausible: POST /api/event (page="/posts/slug/", prev_page="/posts/")
  Engine->>Plausible: Query breakdown(event:props:prev_page, event:page=="/posts/slug/")
  Engine->>Plausible: Query breakdown(event:page, event:props:prev_page=="/posts/slug/")

3. Empirical Step-Transition Aggregation in getPlausibleVisitorFlow()

Empirical step-transition aggregation reconstructs centered 3-stage visitor flows (Upstream -> Current Page -> Downstream) by querying Plausible breakdown endpoints concurrently. Filtering by event:page==p grouped on event:props:prev_page yields exact inbound internal edges, while inverting the filter to event:props:prev_page==p grouped on event:page captures all outbound internal transitions.

Once events are ingested with prev_page properties, how do we reconstruct the complete bidirectional traffic flow centered on a specific target page pp (for example, p=/posts/2026_09_23_unified_analytics_mcp_server_seo_geo_aio/p = \text{\texttt{/posts/2026\_09\_23\_unified\_analytics\_mcp\_server\_seo\_geo\_aio/}})?

In graph-theoretic terms, let G=(V,E)G = (V, E) be the directed navigation graph where vertices VV represent normalized internal URL paths augmented with an external source boundary state sentrys_{\text{entry}} and termination states {sbounce,sexit}\{s_{\text{bounce}}, s_{\text{exit}}\}. For any focal vertex p∈Vp \in V, our server-side function getPlausibleVisitorFlow() in src/commons/server/analytics/clients/plausible.ts executes six concurrent HTTP breakdown queries via Promise.all against the Plausible Stats API [Plau24]:

// Excerpt from src/commons/server/analytics/clients/plausible.ts
const [
  prevPagesRaw,
  nextPagesRaw,
  inboundSourcesRaw,
  fileDownloadUrlsRaw,
  highIntentPdfNamesRaw,
  outboundLinkUrlsRaw,
] = await Promise.all([
  fetchWithAlt('event:props:prev_page', (p) => `event:page==${p}`, 25, 'visitors,events'),
  fetchWithAlt('event:page', (p) => `event:props:prev_page==${p}`, 25, 'visitors,events'),
  fetchWithAlt(
    'visit:source',
    (p) => `event:page==${p};event:props:prev_page!=(none)`,
    12
  ),
  fetchWithAlt(
    'event:props:url',
    (p) => `event:name==File Download;event:page==${p};event:props:prev_page!=(none)`,
    15
  ),
  fetchWithAlt(
    'event:props:file',
    (p) => `event:name==High Intent: PDF Download;event:page==${p};event:props:prev_page!=(none)`,
    15
  ),
  fetchWithAlt(
    'event:props:url',
    (p) => `event:name==Outbound Link: Click;event:page==${p};event:props:prev_page!=(none)`,
    15
  ),
]);

Look closely at the symmetry and query mechanics across these six concurrent requests:

  1. Trailing-Slash Resilience (fetchWithAlt): Historical Plausible records or external links occasionally record URLs without a trailing slash (altPath). Our wrapper fetchWithAlt queries the canonical trailing-slash path normPath first and transparently retries with altPath only if the primary breakdown returns zero rows.
  2. Inbound Edge Distribution (u→pu \to p): Querying property event:props:prev_page with filter event:page==p and metrics = 'visitors,events' partitions all pageviews on pp by their immediate predecessor. Using row.events || row.visitors ensures we count total step-transition edge traversals rather than deduplicating repeat navigations within the same visit. Rows where prev_page === '(entry)' (or self-referential reloads where pNorm === normPath) accumulate into Ndirect(p)N_{\text{direct}}(p) (directEntries). Rows where prev_page is a distinct internal path u≠pu \neq p populate the upstream map w(u→p)w(u \to p) (sorted descending and capped at the top 10 edges via .slice(0, 10)), yielding the total internal upstream volume: Nupstream(p)=∑u∈V∖{p}w(u→p)N_{\text{upstream}}(p) = \sum_{u \in V \setminus \{p\}} w(u \to p) The total empirical flow through vertex pp is therefore strictly defined by its inbound conservation sum: Ntotal(p)=Ndirect(p)+Nupstream(p)N_{\text{total}}(p) = N_{\text{direct}}(p) + N_{\text{upstream}}(p)
  3. Outbound Edge Distribution (p→vp \to v): By inverting the query—requesting property event:page filtered by event:props:prev_page==p with metrics = 'visitors,events'—we ask Plausible: “Across the entire site, which pageviews declared pp as their immediate prev_page?” This yields the raw downstream transition counts wraw(p→v)w_{\text{raw}}(p \to v) (capped at the top 10 destinations via .slice(0, 10)) for every internal destination page v≠pv \neq p without ever needing a session ID to link the two pageviews.
  4. Cohort-Scoped Sources and Orthogonal Micro-Conversions (stepActions): The remaining four queries append ;event:props:prev_page!=(none) so that external acquisition sources (visit:source) and in-page micro-conversions (File Download, High Intent: PDF Download, and Outbound Link: Click) are strictly scoped to the post-upgrade telemetry cohort where prev_page is active. Crucially, stepActions[normPath] models orthogonal in-page engagement overlays rather than absorbing graph terminations: a visitor who downloads a PDF lecture handout or clicks an external GitHub citation frequently continues reading and subsequently transitions to a downstream internal page vv.

What happens when a single visitor on page pp opens multiple downstream articles in separate tabs, causing the raw outbound sum ∑vwraw(p→v)\sum_v w_{\text{raw}}(p \to v) to exceed the inbound pageview count Ntotal(p)N_{\text{total}}(p)? And how do we partition visitors who do not continue to another internal page between single-page Bounces (NbounceN_{\text{bounce}}) and multi-page Exits (NexitN_{\text{exit}})?

In getPlausibleVisitorFlow(), if the raw downstream sum exceeds Ntotal(p)N_{\text{total}}(p), we proportionally scale the downstream vector back to Ntotal(p)N_{\text{total}}(p) via Largest Remainder apportionment (detailed in Section 5). The remaining non-continuing visits Nterm(p)=max⁡(0,Ntotal(p)−Nnext(p))N_{\text{term}}(p) = \max(0, N_{\text{total}}(p) - N_{\text{next}}(p)) are then split between bounces (which by definition can only arise from directEntries) and multi-page exits:

Nbounce(p)=min⁡ ⁣(Nterm(p),  min⁡ ⁣(Ndirect(p),  round⁡(rbounce(p)⋅Ndirect(p))))N_{\text{bounce}}(p) = \min\!\Big(N_{\text{term}}(p),\; \min\!\big(N_{\text{direct}}(p),\; \operatorname{round}(r_{\text{bounce}}(p) \cdot N_{\text{direct}}(p))\big)\Big)

Nexit(p)=max⁡ ⁣(0,  Nterm(p)−Nbounce(p))N_{\text{exit}}(p) = \max\!\big(0,\; N_{\text{term}}(p) - N_{\text{bounce}}(p)\big)

where rbounce(p)=baseBounceRate/100∈[0,1]r_{\text{bounce}}(p) = \text{baseBounceRate} / 100 \in [0, 1] is the normalized empirical page bounce rate converted from Plausible’s percentage metric (with a proportional fallback Nbounce(p)=min⁡ ⁣(Nterm(p),round⁡(Nterm(p)⋅Ndirect(p)/Ntotal(p)))N_{\text{bounce}}(p) = \min\!\big(N_{\text{term}}(p), \operatorname{round}(N_{\text{term}}(p) \cdot N_{\text{direct}}(p) / N_{\text{total}}(p))\big) if baseBounceRate is null).

4. How Does First-Order Markov Chain Modeling Reconstruct Historical Visitor Flows?

When granular prev_page custom properties are absent in historical datasets, first-order Markov chain modeling reconstructs probable navigation flows from aggregate boundary distributions (visit:entry_page, visit:exit_page, and bounce_rate). Under the memoryless Markov property, transition probabilities depend solely on the current page state, enabling stateless flow synthesis across arbitrary time windows.

Empirical prev_page step tracking solves the problem for all traffic recorded after our telemetry upgrade. However, two structural analytical needs remain:

  1. Historical Continuity: When analyzing 90-day, 6-month, or year-over-year cohorts that include pageviews recorded before the prev_page custom property was deployed, filtering by event:props:prev_page!=(none) omits historical traffic.
  2. Macro-Boundary Flow Synthesis: Even in current windows, comparing one-step empirical transitions (?scope=step) against session-boundary transition models (?scope=markov) reveals whether visitors traverse directly between two pages or arrive via intermediate hubs.

In web usage mining literature, modeling user navigation as a discrete-time first-order Markov chain is a foundational technique established by Sarukkai [Sar00], Borges and Levene [BL00], and Deshpande and Karypis [DK04]. Formally, let {Xt}t≥0\{X_t\}_{t \ge 0} be a stochastic process over the finite state space of website pages and boundary states:

S={sentry}∪V∪{sbounce,sexit}\mathcal{S} = \{s_{\text{entry}}\} \cup V \cup \{s_{\text{bounce}}, s_{\text{exit}}\}

The first-order Markov property states that the conditional probability distribution of the next navigation state Xt+1X_{t+1} given the entire historical trajectory X0,X1,…,XtX_0, X_1, \dots, X_t depends exclusively on the current state Xt=pX_t = p [Sar00] [DK04]:

P(Xt+1=v∣Xt=p,Xt−1=u,…,X0=sentry)=P(Xt+1=v∣Xt=p)=Pp,vP(X_{t+1} = v \mid X_t = p, X_{t-1} = u, \dots, X_0 = s_{\text{entry}}) = P(X_{t+1} = v \mid X_t = p) = P_{p,v}

where the transition matrix P=[Pp,v]\mathbf{P} = [P_{p,v}] is row-stochastic, satisfying Pp,v≥0P_{p,v} \ge 0 for all p,v∈Sp, v \in \mathcal{S} and ∑v∈SPp,v=1\sum_{v \in \mathcal{S}} P_{p,v} = 1 for every non-absorbing state p∈Vp \in V [BL00].

N_direct(p)w(u - p)P_{p,v} * N_total(p)(P_{p,bounce} + P_{p,exit}) *N_total(p)Direct / External Entry (s_entry)Focal Page State (X_t = p)Upstream Internal Pages (u != p)Downstream Internal Pages (v!= p)Absorbing Terminations(s_bounce, s_exit)First-Order Markov State Space Around Focal Page p
N_direct(p)w(u - p)P_{p,v} * N_total(p)(P_{p,bounce} + P_{p,exit}) *N_total(p)Direct / External Entry (s_entry)Focal Page State (X_t = p)Upstream Internal Pages (u != p)Downstream Internal Pages (v!= p)Absorbing Terminations(s_bounce, s_exit)First-Order Markov State Space Around Focal Page p
Directed state transition topology from entry sources and upstream pages through focal state p to downstream states and absorbing terminations.
View Diagram Source
---
title: "First-Order Markov State Space Around Focal Page p"
caption: "Directed state transition topology from entry sources and upstream pages through focal state p to downstream states and absorbing terminations."
---
flowchart LR
  Entry["Direct / External Entry (s_entry)"] -->|"N_direct(p)"| Focal["Focal Page State (X_t = p)"]
  Upstream["Upstream Internal Pages (u != p)"] -->|"w(u -> p)"| Focal
  Focal -->|"P_{p,v} * N_total(p)"| Downstream["Downstream Internal Pages (v != p)"]
  Focal -->|"(P_{p,bounce} + P_{p,exit}) * N_total(p)"| Term["Absorbing Terminations (s_bounce, s_exit)"]

Deriving Transition Distributions from Aggregate Plausible Dimensions in getPlausibleMarkovFlow()

From a stochastic modeling standpoint, it is important to distinguish between the exact 1-step Markov transition kernel P(Xt+1=v∣Xt=p)P(X_{t+1} = v \mid X_t = p) measured directly via prev_page in Section 3 and the collapsed 3-stage boundary Markov surrogate (X0→Xt=p→XTX_0 \to X_t = p \to X_T) synthesized when step-level edges are unavailable. When prev_page is absent in historical logs, Plausible still records native session-boundary dimensions for every page p∈Vp \in V: specifically, visit:entry_page filtered by event:page==p (the distribution of initial session entry states X0=uX_0 = u for visits passing through Xt=pX_t = p) and visit:exit_page filtered by event:page==p (the distribution of final session exit states XT=vX_T = v for visits passing through Xt=pX_t = p), alongside aggregate visitors, pageviews, entries (EpE_p), and bounce_rate (rbounce(p)r_{\text{bounce}}(p)) [Plau24].

In getPlausibleMarkovFlow() within src/commons/server/analytics/clients/plausible.ts, we construct this collapsed first-order Markov boundary flow around focal page pp with total volume Ntotal(p)=max⁡(basePageviews,baseVisitors)N_{\text{total}}(p) = \max(\text{basePageviews}, \text{baseVisitors}) in four deterministic steps:

  1. Candidate Upstream and Downstream Boundary Extraction: We query visit:entry_page and visit:exit_page conditioned on event:page==p, excluding self-loops (u=pu = p and v=pv = p). Let u~i=vis(entry_page=ui∣page=p)\tilde{u}_i = \text{vis}(\text{entry\_page} = u_i \mid \text{page} = p) for i∈{1,…,kup}i \in \{1, \dots, k_{\text{up}}\} and v~j=vis(exit_page=vj∣page=p)\tilde{v}_j = \text{vis}(\text{exit\_page} = v_j \mid \text{page} = p) for j∈{1,…,kdown}j \in \{1, \dots, k_{\text{down}}\} denote the top-8 empirical boundary weights (capped via .slice(0, 8)).
  2. Inbound Partitioning (NdirectN_{\text{direct}} vs. NupstreamN_{\text{upstream}}): If no non-self entry pages exist (∑iu~i=0\sum_i \tilde{u}_i = 0), all visits entered directly at pp: Ndirect(p)=Ntotal(p)N_{\text{direct}}(p) = N_{\text{total}}(p) and Nupstream(p)=0N_{\text{upstream}}(p) = 0. Otherwise, using Plausible’s native baseEntries count EpE_p (or falling back to max⁡(0,Ntotal(p)−∑iu~i)\max(0, N_{\text{total}}(p) - \sum_i \tilde{u}_i) if baseEntries === null): Ndirect(p)=min⁡ ⁣(Ntotal(p),  max⁡(0,Ep)),Nupstream(p)=max⁡ ⁣(0,  Ntotal(p)−Ndirect(p))N_{\text{direct}}(p) = \min\!\big(N_{\text{total}}(p),\; \max(0, E_p)\big), \qquad N_{\text{upstream}}(p) = \max\!\big(0,\; N_{\text{total}}(p) - N_{\text{direct}}(p)\big) Each upstream candidate uiu_i is then assigned its conditional Markov inflow quota: qiup=u~i∑m=1kupu~m⋅Nupstream(p)q^{\text{up}}_i = \frac{\tilde{u}_i}{\sum_{m=1}^{k_{\text{up}}} \tilde{u}_m} \cdot N_{\text{upstream}}(p)
  3. Bounce Isolation (NbounceN_{\text{bounce}}): Visitors who entered at pp and immediately left without triggering another pageview are bounded by directEntries and estimated via baseBounceRate: Nbounce(p)=min⁡ ⁣(Ndirect(p),  round⁡ ⁣(rbounce(p)⋅Ndirect(p)))N_{\text{bounce}}(p) = \min\!\Big(N_{\text{direct}}(p),\; \operatorname{round}\!\big(r_{\text{bounce}}(p) \cdot N_{\text{direct}}(p)\big)\Big)
  4. Outbound Partitioning (NnextN_{\text{next}} vs. NexitN_{\text{exit}}): Non-bouncing visits available for downstream continuation are bounded by Ntotal(p)−Nbounce(p)N_{\text{total}}(p) - N_{\text{bounce}}(p). Thus: Nnext(p)=min⁡ ⁣(max⁡ ⁣(0,  Ntotal(p)−Nbounce(p)),  ∑j=1kdownv~j)N_{\text{next}}(p) = \min\!\left(\max\!\big(0,\; N_{\text{total}}(p) - N_{\text{bounce}}(p)\big),\; \sum_{j=1}^{k_{\text{down}}} \tilde{v}_j\right) Each downstream state vjv_j receives exact Markov outflow quota qjdown=v~j∑mv~m⋅Nnext(p)q^{\text{down}}_j = \frac{\tilde{v}_j}{\sum_m \tilde{v}_m} \cdot N_{\text{next}}(p), and the residual multi-page terminations settle into absorbing exit state sexits_{\text{exit}}: Nexit(p)=max⁡ ⁣(0,  Ntotal(p)−Nbounce(p)−Nnext(p))N_{\text{exit}}(p) = \max\!\big(0,\; N_{\text{total}}(p) - N_{\text{bounce}}(p) - N_{\text{next}}(p)\big)

Here is the core implementation in src/commons/server/analytics/clients/plausible.ts:

// Excerpt from getPlausibleMarkovFlow() in src/commons/server/analytics/clients/plausible.ts
const sumUpstream = upstreamPages.reduce((acc, item) => acc + item.visitors, 0);
const sumDownstream = downstreamPages.reduce((acc, item) => acc + item.visitors, 0);

let directEntries: number;
let internalUpstreamVisitors: number;
if (upstreamPages.length === 0 || sumUpstream <= 0) {
  directEntries = totalViews;
  internalUpstreamVisitors = 0;
  upstreamPages = [];
} else {
  directEntries =
    baseEntries !== null
      ? Math.min(totalViews, Math.max(0, baseEntries))
      : Math.max(0, totalViews - sumUpstream);
  internalUpstreamVisitors = Math.max(0, totalViews - directEntries);
  upstreamPages = scaleItemsToTargetTotal(upstreamPages, internalUpstreamVisitors, denom);
}

const bounceVisitors =
  baseBounceRate !== null
    ? Math.min(directEntries, Math.round((baseBounceRate / 100) * directEntries))
    : 0;

let internalNextVisitors: number;
if (downstreamPages.length === 0 || sumDownstream <= 0) {
  internalNextVisitors = 0;
  downstreamPages = [];
} else {
  internalNextVisitors = Math.min(Math.max(0, totalViews - bounceVisitors), sumDownstream);
  downstreamPages = scaleItemsToTargetTotal(downstreamPages, internalNextVisitors, denom);
}

const exitVisitors = Math.max(0, totalViews - bounceVisitors - internalNextVisitors);

5. Enforcing Flow Conservation via Hamilton’s Largest Remainder Method

Standard floating-point rounding (Math.round) violates flow conservation in Sankey diagrams because rounded branch counts rarely sum to the exact target total. Applying Hamilton’s Largest Remainder apportionment (scaleItemsToTargetTotal) guarantees that integer visitor allocations across upstream and downstream edges sum exactly to NtotalN_{\text{total}} with zero rounding drift.

In any multi-column flow visualization or conservation-checked MCP telemetry payload, Kirchhoff’s current law for visitor traffic must hold strictly in integer visitor counts:

Ndirect(p)+∑i=1kupniup⏟Total Inbound Flow  =  Ntotal(p)  =  ∑j=1kdownnjdown+Nbounce(p)+Nexit(p)⏟Total Outbound Flow\underbrace{N_{\text{direct}}(p) + \sum_{i=1}^{k_{\text{up}}} n^{\text{up}}_i}_{\text{Total Inbound Flow}} \;=\; N_{\text{total}}(p) \;=\; \underbrace{\sum_{j=1}^{k_{\text{down}}} n^{\text{down}}_j + N_{\text{bounce}}(p) + N_{\text{exit}}(p)}_{\text{Total Outbound Flow}}

Suppose Nupstream(p)=10N_{\text{upstream}}(p) = 10 visitors are distributed equally across three upstream pages with raw weights u~=(1,1,1)\tilde{u} = (1, 1, 1). Naive rounding of the exact rational quotas qi=103≈3.333q_i = \frac{10}{3} \approx 3.333 produces (3,3,3)(3, 3, 3), whose sum is 9≠109 \neq 10—leaking 10% of the flow and causing visible Sankey node-height mismatches. Conversely, if three pages have quotas (3.6,3.6,2.8)(3.6, 3.6, 2.8), naive Math.round yields (4,4,3)=11>10(4, 4, 3) = 11 > 10, inventing phantom traffic.

To eliminate rounding drift mathematically, scaleItemsToTargetTotal in src/commons/server/analytics/clients/plausible.ts implements Alexander Hamilton’s Largest Remainder Method (1792), thoroughly axiomatized by Balinski and Young [BY82] in the theory of proportional apportionment:

  1. Exact Quota Calculation: Given raw positive counts v1,…,vkv_1, \dots, v_k with sum V=∑i=1kviV = \sum_{i=1}^k v_i and an integer target total T∈Z≥0T \in \mathbb{Z}_{\ge 0}, compute each item’s exact real quota: qi=viV⋅Tq_i = \frac{v_i}{V} \cdot T
  2. Integer Floor & Fractional Remainder Decomposition: Decompose each quota qiq_i into its lower quota (integer floor) and strictly bounded fractional remainder: ni(0)=⌊qi⌋∈Z≥0,ri=qi−⌊qi⌋∈[0,1)n_i^{(0)} = \lfloor q_i \rfloor \in \mathbb{Z}_{\ge 0}, \qquad r_i = q_i - \lfloor q_i \rfloor \in [0, 1)
  3. Deficit Computation: Because 0≤ri<10 \le r_i < 1 for all i∈{1,…,k}i \in \{1, \dots, k\} and ∑i=1kqi=T\sum_{i=1}^k q_i = T, the integer deficit DD satisfies: D=T−∑i=1k⌊qi⌋=∑i=1kri∈{0,1,…,k−1}D = T - \sum_{i=1}^k \lfloor q_i \rfloor = \sum_{i=1}^k r_i \in \{0, 1, \dots, k - 1\}
  4. Largest Remainder Allocation & Deterministic Tie-Breaking: Sort the indices {1,…,k}\{1, \dots, k\} in descending order of their fractional remainders r(1)≥r(2)≥⋯≥r(k)r_{(1)} \ge r_{(2)} \ge \dots \ge r_{(k)}, and award +1+1 visitor to the top DD items: n(i)={⌊q(i)⌋+1if 1≤i≤D,⌊q(i)⌋if D<i≤k.n_{(i)} = \begin{cases} \lfloor q_{(i)} \rfloor + 1 & \text{if } 1 \le i \le D, \\ \lfloor q_{(i)} \rfloor & \text{if } D < i \le k. \end{cases} When two or more branches share identical fractional remainders (ri=rjr_i = r_j, as in the equal-weight case u~=(1,1,1),T=10\tilde{u} = (1, 1, 1), T = 10 where r1=r2=r3=1/3r_1 = r_2 = r_3 = 1/3 and D=1D = 1), ECMAScript’s guaranteed stable Array.prototype.sort preserves the initial index order idx (which is pre-sorted by descending raw visitor count), breaking ties deterministically without random jitter.

By construction, Hamilton’s method satisfies the Quota Rule (∣ni−qi∣<1|n_i - q_i| < 1 for every branch ii) and guarantees exact conservation of flow (∑i=1kni=T\sum_{i=1}^k n_i = T) in O(klog⁡k)O(k \log k) time [BY82]:

// Excerpt from src/commons/server/analytics/clients/plausible.ts
const scaleItemsToTargetTotal = (
  items: VisitorFlowItem[],
  targetTotal: number,
  denomVal: number
): VisitorFlowItem[] => {
  if (items.length === 0 || targetTotal <= 0) return [];
  const sumRaw = items.reduce((s, x) => s + x.visitors, 0);
  if (sumRaw <= 0) return [];
  if (sumRaw === targetTotal) {
    return items.map((item) => ({
      ...item,
      percentage: Number(((item.visitors / denomVal) * 100).toFixed(1)),
    }));
  }

  const exactQuotas = items.map((item, idx) => {
    const exact = (item.visitors / sumRaw) * targetTotal;
    const floored = Math.floor(exact);
    return { idx, item, exact, floored, remainder: exact - floored };
  });

  const currentSum = exactQuotas.reduce((s, q) => s + q.floored, 0);
  let deficit = Math.max(0, Math.round(targetTotal - currentSum));

  const sortedByRemainder = [...exactQuotas].sort((a, b) => b.remainder - a.remainder);
  for (let i = 0; i < sortedByRemainder.length && deficit > 0; i++) {
    sortedByRemainder[i].floored += 1;
    deficit -= 1;
  }

  return exactQuotas
    .map((q) => ({
      ...q.item,
      visitors: q.floored,
      percentage: Number(((q.floored / denomVal) * 100).toFixed(1)),
    }))
    .filter((x) => x.visitors > 0);
};

6. Conclusion: Privacy by Design Without Analytical Blindness

Consent-free visitor flow reconstruction proves that strict EU privacy compliance and deep multi-step navigation analytics are mutually compatible. By combining stateless document.referrer edge capture with first-order Markov models and Hamilton apportionment, engineering teams achieve 100% telemetry coverage while eliminating third-party Consent Management Platform (CMP) overhead and banner-induced selection bias.

Privacy regulation and quantitative systems engineering were historically framed as a zero-sum trade-off: either deploy invasive session trackers behind disruptive cookie consent modals or settle for superficial pageview counters that cannot explain how readers traverse your architecture, evaluate technical artifacts, or convert across internal content hubs. By rethinking visitor flow telemetry from first principles in src/commons/client/telemetry.ts and src/commons/server/analytics/clients/plausible.ts, we proved that both extremes are unnecessary:

  • Zero Terminal Storage & Zero Consent Banners: Eliminating flow_id, sessionStorage, and cookies in favor of stateless same-origin document.referrer (prev_page) normalization aligns 100% with EU ePrivacy Directive Art. 5(3) and EDPB Guidelines 2/2023 [Edp24].
  • Dual-Mode Flow Reconstruction: Combining empirical first-order step queries (getPlausibleVisitorFlow) with first-order Markov boundary synthesis (getPlausibleMarkovFlow) [Sar00] [DK04] gives both human engineers and our Unified Analytics MCP Server complete visibility across real-time and historical navigation cohorts.
  • Exact Conservation Invariants: Enforcing Hamilton’s Largest Remainder apportionment [BY82] ensures that every reconstructed flow graph obeys strict integer conservation from entry source to exit termination.

References