Tracker — spec
Table of Contents
1. Purpose
The tracker is the passive-capture layer of the wal.sh adtech surface.
One browser module (wal-sh.site.tracker.browser) installs a fixed
set of document-level listeners; each dispatches into pure logic in
wal-sh.site.tracker.core. No per-page instrumentation, no data-layer
push calls at the render site — every event derives from a native DOM
event plus data-track-* attributes on the element.
Existence rationale is research, not analytics. The tracker is the control surface for the attribution-audit ledger, the intent-signals accumulator, and the stuffing-detectors suite — those three modules read events the tracker emits. The tracker also matches the shape of the industry passive-capture profile studied in the million-site measurement (Englehardt and Narayanan 2016), so wal.sh's event vocabulary maps onto measurement-paper terminology one-to-one.
ORACLE reference implementation: src/wal_sh/site/tracker/core.cljc
(pure logic) + src/wal_sh/site/tracker/browser.cljs (DOM adapter).
Envelope reuse: wal-sh.adtech.beacon.core/build — the tracker and
the explicit-beacon module produce identical JSON.
2. Requirements
- T1
- One document-level listener per event class. No per-element handlers; the tracker never touches DOM at mount time except to install the observers.
- T2
- Event names come from the closed set in
wal-sh.site.tracker.core/known-events; adding a new event name requires updating the set. - T3
- Every event carries the shared beacon envelope. Element
identity is a sub-map built by
element-payloadfrom a plain map, not a live DOM node — keepscorehost-neutral. - T4
- Transport is a pixel GET (
Image().src) to a same-origin or beacon-host endpoint; piggybacks onimg-src, notconnect-src. See 5. - T5
- Bot UA +
navigator.webdriver= drop the event before serialization. - T6
?tracking=offURL param disables the tracker for the page.- T7
- Total URL length after query-encoding is capped at
max-query-len(1800 bytes); over-cap events are truncated before send. - T8
- Deterministic sampling — the FNV-1a of the session id mod 100 decides all-in or all-out for the page-life. No half-populated funnels.
- T9
sessionIdis opaque, rotating per page-life; no cross-session cookie.- T10
- The tracker exposes
window.__wal_trackerwith.send,.rescan,.session— nothing else. - T11
- Impression fires only when the element is ≥50 % in-view for
≥1 s (
impression-dwell-ms). Below this it is not an impression. - T12
- Hover fires only when the pointer dwells for ≥500 ms
(
hover-dwell-ms). - T13
- Reverse scroll fires once per session, after ≥50 % depth reached, on an upward move ≥200 px.
- T14
- Sponsored events are distinct names (
sponsored.impression,sponsored.click) from organic (element.*); both carryelKindso downstream can join them.
3. Contract signature
Public surface (browser, string-keyed for :advanced-safe interop):
window.__wal_tracker = {
send: (fn [event-name extra] -> nil) ; fire an event with merged extras
rescan: (fn [] -> nil) ; re-run IntersectionObserver over new DOM
session: (fn [] -> string) ; opaque per-page-life id
}
Core (host-neutral, :clj + :cljs):
(scroll-pct scroll-y viewport-h doc-h) ; -> int in [0,100]
(crossed-milestones pct fired-set) ; -> set of newly-crossed
(element-payload attrs-map) ; -> sub-map for envelope
(sample? session-id pct) ; -> bool, deterministic
(valid-event? name) ; -> bool
(clip s) ; -> s truncated to max-attr-len
(sanitize-el-attrs m) ; -> m with every string clipped
4. Event catalogue
| Event | Trigger | Sampling |
|---|---|---|
pageview |
init | 100 % |
page.dwell |
visibilitychange=hidden or pagehide |
100 % |
scroll.depth |
25 / 50 / 75 / 100 % milestones | 100 % (4 max) |
scroll.reverse |
≥200 px up after ≥50 % depth | first-per-session |
element.impression |
IntersectionObserver ≥50 % ≥1 s | 100 % |
element.dwell |
element leaves viewport after impression | 100 % |
element.click |
pointerup on <a> / <button> / [data-track-id] |
100 % |
element.hover |
pointerover ≥500 ms | 100 % |
sponsored.impression |
as element.impression, restricted to data-track-kind=sponsored-* |
100 % |
sponsored.click |
as element.click, restricted | 100 % |
form.submit |
form submit | 100 % |
form.focus |
focusin on input/textarea/select | 100 % |
5. Transport (v6)
Was: POST beacon.termbox.org/webhook via sendBeacon / fetch
keepalive. Blocked by connect-src 'self' in the deployed CSP.
Now: Image().src GET to beacon.termbox.org/pixel; payload encoded
as query params, response is a 1×1 GIF (MDN Web Docs, n.d.-a). See the
OpenAPI spec at docs/beacon-termbox-spec.json. Same-origin fallback
/static/t.gif for hosts where img-src does not permit the beacon
host. Access logs on the pixel host become the analytics stream.
The pixel technique predates and outlasts sendBeacon (MDN Web Docs, n.d.-c);
Roesner et al. classify the same shape as "vanilla" third-party tracking
(Roesner, Kohno, and Wetherall 2012).
6. Sampling + gating
sample?is FNV-1a ofsessionIdmod 100 vs a per-class percentage. All-in or all-out for the page-life; funnels stay monotone.- Bot UAs and
navigator.webdriverdrop the event atsendtime before URL construction. Crawler traffic remains uncounted; see Ahmad et al. on the reverse problem — measurements taken via crawlers over-count what the browser actually renders (Ahmad et al. 2020), (Jueckstock et al. 2021). ?tracking=offdisables per-page. Documented in the site's own privacy note.max-query-len(1800) is the practical CDN truncation floor; the spec-legal URL length is higher but many intermediaries truncate.
7. Related literature
- Roesner, Kohno & Wetherall define the passive-capture profile wal.sh matches: single script, document-level listeners, GET pixel (Roesner, Kohno, and Wetherall 2012).
- Englehardt & Narayanan's million-site OpenWPM crawl is the
reference measurement for this event vocabulary; the
known-eventsset maps onto their observed handlers (Englehardt and Narayanan 2016). - Acar et al. on persistent identifiers explains why wal.sh's tracker refuses to write a cross-session cookie: any stable identifier is a fingerprint substrate (Acar et al. 2014).
- IntersectionObserver semantics: MDN (MDN Web Docs, n.d.-b).
sendBeaconwas the intended transport before CSP forced the switch; MDN documents thekeepalivefallback pattern (MDN Web Docs, n.d.-c).Image()constructor as pixel transport: MDN (MDN Web Docs, n.d.-a).- Sec-Fetch-Site is the browser-side signal that maps onto our
sponsored?predicate at the origin level (W3C 2021). rel=sponsoredis the outbound-link analogue of our sponsored event vocabulary (WHATWG, n.d.).
8. Cross-references
- beacon/spec.org — envelope + serialization contract the tracker reuses verbatim.
- attribution-audit/spec.org — reads
sponsored.*+element.clickevents into the provenance ledger. - intent-signals/spec.org — reads
page.dwell,scroll.depth,element.clickinto the score accumulator. - exit-intent/spec.org — reads
scroll.reverse+ intent-signals eligibility. - pocket-es-integration/spec.org — calls
rescanafter search results mount so newly-inserted DOM is observed. - ORACLE:
src/wal_sh/site/tracker/core.cljc(pure)src/wal_sh/site/tracker/browser.cljs(DOM adapter)
- [BROKEN LINK: No match for fuzzy expression: *2013–2018: Mobile + programmatic era] on how event vocabularies standardised via IAB frameworks.
- Web Trackers Analysis (Alexa Top 800) — 2012 Graphviz map of third-party tracker ecosystem (DoubleClick / Comscore / Quantcast / BlueKai); the passive-capture surface this tracker inverts.
- SLIDR: Real-Time Robotic Traffic Detection — the invalid-traffic detection layer that consumes the raw event stream this tracker emits.
9. Open questions
- Batching. Current tracker fires one pixel per event; a 1 s debounce with flush on visibility-change would cut request count 10-40x at the cost of losing the last N events on a hard tab-kill. The pixel-transport switch (v6) made this less urgent — one GIF fetch is cheap — but not moot. Design decision: preserve tail-latency events or preserve request count.
- Sampling percentage per event class. Everything is currently 100 %
— the site's traffic is low enough that sampling is not a cost
driver. If the site scales,
sample?is already deterministic- per-session and lets us drop entire classes without funnel damage.