You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
First-party traffic measurement for portals: what visitors look at, how they
move between pages, and where they arrive from and leave. Modelled on the GA4
event vocabulary so the numbers mean what people already expect them to mean,
but what is collected is configured per portal, and the collector is our
own — a government portal should not be posting its visitors to a third party
to find out that its Woo page is popular.
The client half ships with docusaurus-plugin-portaliq, because a
Docusaurus-rendered portal is exactly the case that cannot be measured any
other way.
0.1 Determine what portalSession can answer. Nothing about pages: its fields are subjectRef, jti, issuedAt, expiresAt, revoked, trustLevel, audience, organisation, and it exists only for authenticated portal users. Anonymous visitors — the bulk of a public portal — have none.
0.2 Determine whether OpenRegister already records page reads. It does not.ProcessingLogService::logRead() exists and works, but is opt-in per schema via logReads, and lib/Settings/portaliq_register.json enables it on 0 of 13 schemas.
0.3 Determine whether switching it on would answer the question. No. It is an AVG verwerkingsregister: object-level, actor-attributed, with no visit, referrer, entrance, exit or ordering. It would also make an accountability record carry analytics traffic.
0.4 Determine whether a Docusaurus-rendered portal can be measured server-side. No. The plugin fetches content at BUILD time and emits static HTML hosted elsewhere; portaliq is not in the request path when a visitor reads it.
0.5 Write the finding into the openspec archive as the reason the design is client-reported, so the next person does not re-derive it by switching on logReads and believing the table. Recorded in proposal.md under "What we already know" (the measured table and "The one thing that would look like it works") and in the spec's requirement "A page view MUST be reported by the client, never inferred from a server-side read". Phase 0 (2026-09-04) built exactly that: the collector, the served client, the aggregation job and the Traffic page read nothing from logReads, portalSession or the access log.
1.1 Define the event envelope: clientId, sessionId, sequence, name, timestamp (client clock), pageLocation, pageReferrer, pageTitle, plus a bounded params map.
1.2 Define the shipped event vocabulary — page_view, session_start, scroll, outbound_click, file_download, search, form_submit — with the GA4 name for each, so a number here and a number in GA4 mean the same thing.
1.3 Define the portalTrafficEvent schema in the register (and portalTrafficDaily for the rollups; portal.kind and portal.traffic on the portal).
1.4 (deferred to phase 1) Test: an event whose sequence repeats within a session is rejected — a client that resets its counter must not silently corrupt a journey.
2.2 Serve the resolved configuration to the client over the public content contract, so the client sends only what the portal asked for.
2.3 Test: a portal enabling only page_view gets exactly that — the collector refuses search, and the refusal is counted, not silent.
2.4 Test: a configuration field is not merely stored — assert the collector's BEHAVIOUR changes with it. A declared-but-unread config field is this codebase's most repeated defect.
3.1 POST /api/traffic — anonymous, batched, sendBeacon-compatible, responding 204 with no body and no cookie.
3.2 Resolve the serving portal the same way the renderer does (host, then explicit slug), so an event cannot be attributed to a portal the caller names.
3.3 Derive coarse region from the request IP and DISCARD the IP in the same request. Test that no stored field, log line or aggregate contains it.
3.4 Rate-limit per client id and per source, refusing the excess with a counted reason.
3.5 Refuse an oversized or malformed batch WHOLE — never store a partial batch.
3.6 Test: the endpoint is genuinely anonymous. A guard nobody has watched refuse is untested — assert a real anonymous request succeeds AND that a disabled portal's collector refuses.
4.1 Close sessions after the configured inactivity window; a later event starts a new session.
4.2 Reconstruct journeys by sequence, not by receipt time. Test with events delivered out of order — the case that only shows up on slow connections.
4.3 Aggregate: views per page, entrances, exits, transitions between pages, sessions, engaged sessions, average engagement time.
4.4 Delete raw events past the retention window; keep the aggregates.
4.5 Test: the aggregation job is idempotent — running it twice does not double a count.
5.1 A small first-party script: generates and stores the client id, maintains the session and sequence, sends batched beacons, and sends only the configured events.
5.2 (the plugin repo emits the tag; tracked there) Ship it from docusaurus-plugin-portaliq so a statically built portal reports the same events as a server-rendered one, posting cross-origin to its portal's collector.
5.3 Wire the same client into the built-in site renderer, from the same source, so the two cannot drift.
5.4 Honour Do Not Track and the portal's consent posture before writing anything to browser storage.
5.5 Test: with measurement disabled the script sends NOTHING and stores NOTHING — assert both, because a script that stores an id and sends nothing still sets a cookie.
6.1 Replace the three placeholder counters with the aggregates.
6.2 Show the journey: top entrances, top exits, most-travelled transitions.
6.3 Say "not measured" for a portal with measurement disabled, and never render an empty chart for it — a zero and an unmeasured are different facts.
6.4 Test: a portal with no data and a portal with measurement off render DIFFERENTLY.
7.1 Document the privacy posture in the portal admin: what is collected, what is never collected, how long it is kept.
7.2 (openregister repo) Record in openregister that its read log is deliberately NOT the traffic source, so the two are not conflated later.
Synced from openspec/changes/portal-traffic-analytics by OpenSpec workflow App: portaliq
Artifacts
Summary
First-party traffic measurement for portals: what visitors look at, how they
move between pages, and where they arrive from and leave. Modelled on the GA4
event vocabulary so the numbers mean what people already expect them to mean,
but what is collected is configured per portal, and the collector is our
own — a government portal should not be posting its visitors to a third party
to find out that its Woo page is popular.
The client half ships with
docusaurus-plugin-portaliq, because aDocusaurus-rendered portal is exactly the case that cannot be measured any
other way.
Specs
Tasks
portalSessioncan answer. Nothing about pages: its fields aresubjectRef,jti,issuedAt,expiresAt,revoked,trustLevel,audience,organisation, and it exists only for authenticated portal users. Anonymous visitors — the bulk of a public portal — have none.ProcessingLogService::logRead()exists and works, but is opt-in per schema vialogReads, andlib/Settings/portaliq_register.jsonenables it on 0 of 13 schemas.logReadsand believing the table. Recorded inproposal.mdunder "What we already know" (the measured table and "The one thing that would look like it works") and in the spec's requirement "A page view MUST be reported by the client, never inferred from a server-side read". Phase 0 (2026-09-04) built exactly that: the collector, the served client, the aggregation job and the Traffic page read nothing fromlogReads,portalSessionor the access log.clientId,sessionId,sequence,name,timestamp(client clock),pageLocation,pageReferrer,pageTitle, plus a boundedparamsmap.page_view,session_start,scroll,outbound_click,file_download,search,form_submit— with the GA4 name for each, so a number here and a number in GA4 mean the same thing.portalTrafficEventschema in the register (andportalTrafficDailyfor the rollups;portal.kindandportal.trafficon the portal).sequencerepeats within a session is rejected — a client that resets its counter must not silently corrupt a journey.portalschema:traffic: { enabled, events[], dimensions[], sessionTimeoutMinutes, retentionDays, consent: { required, preConsentEvents[] }, regionGranularity }.page_viewgets exactly that — the collector refusessearch, and the refusal is counted, not silent.POST /api/traffic— anonymous, batched,sendBeacon-compatible, responding 204 with no body and no cookie.sequence, not by receipt time. Test with events delivered out of order — the case that only shows up on slow connections.docusaurus-plugin-portaliqso a statically built portal reports the same events as a server-rendered one, posting cross-origin to its portal's collector.openregisterthat its read log is deliberately NOT the traffic source, so the two are not conflated later.Synced from
openspec/changes/portal-traffic-analyticsby OpenSpec workflowApp:
portaliq