Tracker rebuild spec — site-wide attribution from a blank editor
Impression, click, scroll, hover, dwell, form — 11 event types, one pixel envelope

Table of Contents

1. 1. What this document is

A specification you can use to rebuild the wal.sh passive-capture tracker from a blank editor. No dependency on reading the current CLJS. Every claim below is either a design constant with a stated reason, or a numbered requirement that the rebuild MUST satisfy.

The current implementation lives at src/wal_sh/site/tracker/{core.cljc,browser.cljs} and src/wal_sh/adtech/beacon/core.cljc (payload builder, shared with adtech beacon). This document freezes the contract those files implement so a rewrite in TS, Python-transcrypt, or plain JS can drop-in-replace it.

Scope: the first-party observation layer. Sponsored-slot fill, attribution audit, and A/B assignment are consumers of this layer; they are out of scope.

2. 2. Event vocabulary

Eleven event names. Adding a new one requires updating the whitelist in one place (known-events in tracker.core) AND wiring a browser-side emitter for it. The whitelist is a closed set: any event name outside it is rejected at send! time.

Event When Extra fields
pageview on init, once per page-life  
page.dwell on pagehide / hidden-visibility dwellMs, maxScrollPct
scroll.depth first time scroll pct crosses 25/50/75/100 depthPct
scroll.reverse after 50%+ depth, upward scroll ≥ 200px fromPct, toPct
element.impression trackable element ≥50% in-view for 1000ms element identity
element.dwell element leaves viewport after impression fired element identity, dwellMs
element.click click on [data-track-id], <a>, or <button> element identity, modKeys
element.hover pointer over trackable for ≥500ms element identity
sponsored.impression element.impression where sponsored? predicate true element identity
sponsored.click element.click where sponsored? predicate true element identity, modKeys
form.submit any form submit formId, action, fields (count)
form.focus first focus on input/textarea/select tag, name, inputType
result.dwell search-result .pes-hit visible ≥50% for ≥8s (pocket-es specific)

R1. The rebuild MUST honour the closed-set rule for event names. Emitting an unknown name is a no-op. This is the boundary that lets you grep the codebase for "who fires sponsored.impression?" and get an exact answer.

R2. sponsored.* is derived, not primary. Any node whose data-track-kind is sponsored-product or chumbox-card, or that carries data-track-sponsor or data-track-creative, MUST fire the sponsored variant instead of the element variant. Same envelope, same extras — different event name. Consumers can filter on the name.

3. 3. Envelope

Every event, no matter which type, carries the same base envelope before the type-specific extra is merged on top:

Field Source Purpose
event the event name (string, from the whitelist) dispatch
ts Date.now() at emit ordering
session opaque session id (base36 of large random) join within page-life
uid opaque persistent id in localStorage[_ab_uid] join across sessions for the same browser
page location.pathname cluster by URL
ref document.referrer inbound path
tz Intl.DateTimeFormat.resolvedOptions.timeZone rough geo
screen "{w}x{h}" from screen.width/height device coarse-class
userAgent navigator.userAgent bot post-filter
pageID CEDDL digitalData.pageInfo.pageID if present tie to page-model layer

R3. uid MUST persist across sessions in ~localStorage= under key _ab_uid, and MUST be shared with ab-engine and attribution-audit under that same key. The volume-per-uid analysis (bots emit many events per uid; humans emit few) is the whole point of the persistent id — a session-only id can't distinguish "one bot returned 400 times" from "400 users came once."

R4. uid MUST be opaque and NOT PII. No email, no IP, nothing tying it to a person. If localStorage is blocked (private browsing, or a user preference), fall back to the session id — do NOT try to reconstitute persistence via canvas/audio fingerprinting.

R5. The envelope MUST use the exact field names above. Downstream log parsers key on them.

4. 4. Transport — dual pixel GET, not POST

Both the wal.sh CSP and the beacon endpoint's OpenAPI accept a GET /pixel that returns a 1×1 transparent GIF. Loading via new Image()= uses =img-src=, not =connect-src=. The wal.sh CSP is =connect-src 'self'=, so a POST via ~sendBeacon or fetch to beacon.termbox.org is refused; the pixel path is not.

Fan-out to two pixels for every event:

  1. Third-party beacon: https://beacon.termbox.org/pixel?<query>
  2. Same-origin: /static/tracking.gif?<query> (fires an entry in the DreamHost Apache access log; gmake logs rsyncs those; the logs REPL namespace parses them)

R6. The rebuild MUST fire both pixels for every event. The second one is the debugging surface — the beacon endpoint is opaque, but the Apache log is grep-able and joinable in a REPL.

R7. Encode the envelope as a URL query string. Skip nil/blank values. Clip the total encoded string to 1800 bytes so no CDN or proxy truncates mid-parameter.

R8. Never throw from the transport. try {} catch { return null } around ~new Image()= and every listener. A tracker that crashes the page has failed harder than a tracker that silently drops an event.

5. 5. Bot filter + opt-out

Both gates run before any pixel fires. They are cheap; they are not authoritative. A downstream analysis pass with fuller signals (JA3, network timing, etc.) is expected.

R9. bot? returns true if any of:

  • navigator.webdriver === true
  • userAgent contains one of: bot, Bot, crawl, spider, Spider, HeadlessChrome, Playwright, Puppeteer

R10. opt-out? returns true if the URL query has ?tracking=off. This is the user's kill switch and MUST be respected. Document it in your site's privacy page — a switch nobody knows about is not a switch.

R11. Both gates fail closed: on any exception, return false (allow). Otherwise a bad UA string knocks all analytics off the site.

6. 6. Signal collection — six listeners

Every listener is attached in capture phase so descendants that call ~stopPropagation()= (Vue widgets, React portals) don't hide events from us. The listeners themselves are pure dispatchers — signal extraction happens in helpers.

6.1. 6.1 Scroll — depth milestones + reverse

Attach scroll on window, passive: true, wrapped in a requestAnimationFrame throttle (so we sample once per frame, not per pixel).

On each fire:

  1. Compute scrollPct = clamp(0..100, 100 * scrollY / max(1, docHeight - viewportHeight))
  2. For each milestone in [25, 50, 75, 100] that we haven't fired yet and that scrollPct >= milestone, fire scroll.depth with {depthPct: milestone} and record it as fired.
  3. If we've hit ≥50% at any point AND the current scroll delta is ≤ -200px AND we haven't fired reverse yet, fire scroll.reverse with {fromPct: maxScrollPct, toPct: currentPct}.

R12. Milestone fires are one-shot per page-life. A user scrolling 50→60→50→60 does not re-fire scroll.depth 50.

6.2. 6.2 Click — delegated

One document-level listener in capture phase. On each event:

  1. Walk up from event.target via closest("[data-track-id],a,button"). If no match, ignore.
  2. Extract element identity (§7).
  3. Dispatch sponsored.click if the sponsored? predicate holds, element.click otherwise.
  4. Include modKeys (subset of ctrl meta shift alt pressed at click).

6.3. 6.3 Hover — delegated, dwell-thresholded

One pointerover + one pointerout, both capture phase.

On pointerover of a trackable node: start a setTimeout(500ms) that will fire element.hover. Key it by data-track-id (or the node's identity hash if the attr is absent) so multiple concurrent hovers on different nodes each get their own timer.

On ~pointerout=: ~clearTimeout=. Passing over is not hovering.

6.4. 6.4 Impression — IntersectionObserver

One observer, threshold: [0, 0.5, 1], rootMargin: "0px".

On each entry:

  • If isIntersecting AND intersectionRatio >= 0.5: start a setTimeout(1000ms) that fires element.impression (or sponsored.impression via the predicate). Record the entry time.
  • Otherwise: clear the pending timer. If we already fired the impression (i.e. the timer ran to completion), fire element.dwell with the actual time in-view.

R13. The 1000ms floor is what separates an impression from an incidental scroll-past. R14: only 50%+ visible counts — otherwise sticky headers/footers register every element on the page.

Observe every [data-track-id] node currently in the DOM at init. Re-observe on DOMContentLoaded AND on a custom wal:tracker-rescan event so late-mounted content (chumbox, pocket-es results) is picked up without a page reload.

6.5. 6.5 Form — submit + first focus

  • submit=: fire =form.submit= with ~{formId, action, fields: count(elements)}.
  • ~focusin= on ~input/textarea/select=: fire form.focus with ~{tag, name, inputType}=.

R15. Do NOT capture field VALUES — form.focus records that a user touched the field, not what they typed. This is a privacy boundary, not an oversight.

6.6. 6.6 Page-lifecycle dwell

  • Record ~t0 = Date.now()= at init.
  • On ~pagehide= (fires reliably on unload) AND on ~visibilitychange= when ~visibilityState = "hidden"= (fires when the tab is backgrounded), emit page.dwell with ~{dwellMs, maxScrollPct}=.

R16. Both events must be wired; ~pagehide= alone misses the tab-switch case, and ~visibilitychange= alone misses the close-tab case.

7. 7. DOM identity contract

The tracker doesn't know what elements matter — the page tells it via attributes. Ten attributes, none required:

Attr Purpose
data-track-id opaque identifier (kebab-case slug) — presence marks a node as trackable
data-track-kind element class (e.g. sponsored-product, chumbox-card, chip) — routes to sponsored variant
data-track-sponsor sponsor name — presence marks sponsored
data-track-creative creative id — presence marks sponsored
data-track-unit ad unit id (mrec, banner, native)
data-track-page-idx pagination index (int)
data-track-pool-idx index within a pool of similar units (int)
data-track-dup "1" if this is a duplicate render of the same creative
href (standard) link destination — captured from <a> automatically
~data-track-*= (anything) additional attrs are ignored — future-proofing

R17. Every string attribute MUST be clipped to 200 chars server-side in the pure core (clip in tracker.core) before serialization. A pathological data-track-id="{100KB of junk}" MUST NOT blow the pixel budget.

R18. Element identity is extracted in the browser adapter, passed as a plain map to core/element-payload, and merged into the envelope as ~elId=, ~elKind=, ~tag=, ~href=, ~alt=, ~position=, ~sponsor=, ~creativeId=, ~unit=, ~pageIdx=, ~poolIdx=, ~isDup=. The pure core has no DOM dependency; identity extraction is host-specific.

R18a. Image fallback. An <img> (or <figure>) is trackable without a data-track-id — hovering an image is a genuine intent signal, and requiring the page template to annotate every image would leak intent whenever the annotation was forgotten. Fallback identity for an unannotated <img>:

  • elId = "img:" + basename(src.split("?")[0])
  • elKind = "img"
  • href = the src (or the ancestor <a>'s href if the image is inside a link)
  • alt = the alt attribute (never captured for non-image nodes)

Explicit ~data-track-*= attributes always win over fallbacks when present.

8. 8. Sampling — deterministic per-session

R19. Sampling MUST be deterministic on the session id. Given a session and an event class, either every event of that class fires for that session or none does. Random per-event sampling produces half-populated funnels and is worse than no sampling.

Implementation: ~fnv1a(sessionId) mod 100 < pct=. FNV-1a is a 32-bit hash with well-known constants; do not substitute djb2 or java-string-hash — cross-language reproducibility depends on matching bytes.

The current tracker samples at 100% (no sample function called); that constant lives at the send! site.

9. 9. Public API — window.__wal_tracker

Exposed after init! completes:

window.__wal_tracker.send("<event-name>", {extras: "..."})
window.__wal_tracker.rescan()   // re-observe [data-track-id] nodes
window.__wal_tracker.session()  // returns opaque session id

R20. ~send= MUST reject unknown event names (silently — no throw, no console error) so a rogue caller can't inject arbitrary event types into the pipeline.

R21. ~rescan= MUST be idempotent — attaching the observer twice must not fire two impressions per view. Track observed nodes in a Set.

R22. Init MUST be idempotent — a second ~init!()= call must be a no-op (compare-and-set on an ~installed?= atom).

10. 10. Rebuild recipe

Ten steps. In order. Each step MUST be complete before the next begins.

  1. Author a whitelist of event names. Freeze it. Reject anything else.
  2. Author the envelope (§3). Every field, sourced from the same place as this doc.
  3. Author the transport (§4). Dual-fan-out, GET, ~Image().src=. Never throw.
  4. Author the bot filter + opt-out (§5). Both fail closed on error.
  5. Author the pure core: element-payload, scroll math, sampling, event-name validation. No DOM here. Test in isolation.
  6. Author the six listeners (§6.1-6.6) in the browser adapter. Each listener is a pure dispatch — extract the payload, call ~send!=.
  7. Wire the [data-track-*] contract (§7). Update page templates so the elements you care about carry ~data-track-id=.
  8. Wire the public API (§9). Compare-and-set-once on ~installed?=.
  9. Ledger the crossing: bump ~.verify/chain.jsonl= via ~bb scripts/verify-chain add=; verifier tracker-vN-implementer, subject tracker-vN.
  10. Deploy. Verify events appear in ~/logs/wal.sh/https/access.log= for the /static/tracking.gif?... path AND in the beacon endpoint (opaque, so you're verifying by shape).

11. 11. Non-goals and boundaries

Explicitly out of scope for the tracker:

  • Attribution. Which touchpoint gets credit is ~wal-sh.adtech.attribution-audit=. The tracker records touches; crediting them is somebody else's job.
  • A/B assignment. Which arm a user is in is ~wal-sh.adtech.ab-engine=. The tracker records events; the arm goes in via the CEDDL layer.
  • Fill / ranking. Which ad serves is ~sponsored-research=. The tracker records that a sponsored unit was seen; it does not decide which unit.
  • Session reconstitution. The tracker does not attempt to stitch cross-device sessions or reconstruct a user's true identity. ~uid= is opaque and per-browser.

The tracker's only job: turn DOM events into pixel GETs under a closed event vocabulary. Every additional responsibility belongs in a separate contract.

12. 12. Refutation condition

The claim is that this spec is enough to rebuild the tracker without reading ~src/wal_sh/site/tracker/=. It fails if a second implementation, written from this doc alone, produces materially different ~access.log= entries against the same interaction. Test by pointing both implementations at the same page, driving the same sequence (pageview → scroll to 50% → click a tracked link → pagehide), and diffing the ~/static/tracking.gif?…= entries in the log. Difference in envelope key order is fine; difference in which events fire, or in the values of event, depthPct, dwellMs, or elId is a specification bug.

13. Cross-references