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
- 2. 2. Event vocabulary
- 3. 3. Envelope
- 4. 4. Transport — dual pixel GET, not POST
- 5. 5. Bot filter + opt-out
- 6. 6. Signal collection — six listeners
- 7. 7. DOM identity contract
- 8. 8. Sampling — deterministic per-session
- 9. 9. Public API —
window.__wal_tracker - 10. 10. Rebuild recipe
- 11. 11. Non-goals and boundaries
- 12. 12. Refutation condition
- 13. Cross-references
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:
- Third-party beacon:
https://beacon.termbox.org/pixel?<query> - Same-origin:
/static/tracking.gif?<query>(fires an entry in the DreamHost Apache access log;gmake logsrsyncs those; thelogsREPL 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 === trueuserAgentcontains 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:
- Compute
scrollPct = clamp(0..100, 100 * scrollY / max(1, docHeight - viewportHeight)) - For each milestone in
[25, 50, 75, 100]that we haven't fired yet and thatscrollPct >= milestone, firescroll.depthwith{depthPct: milestone}and record it as fired. - If we've hit ≥50% at any point AND the current scroll delta is
≤
-200pxAND we haven't fired reverse yet, firescroll.reversewith{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:
- Walk up from
event.targetviaclosest("[data-track-id],a,button"). If no match, ignore. - Extract element identity (§7).
- Dispatch
sponsored.clickif thesponsored?predicate holds,element.clickotherwise. - Include
modKeys(subset ofctrl meta shift altpressed 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
isIntersectingANDintersectionRatio >= 0.5: start asetTimeout(1000ms)that fireselement.impression(orsponsored.impressionvia 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.dwellwith 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.focuswith ~{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), emitpage.dwellwith ~{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= thesrc(or the ancestor<a>'s href if the image is inside a link)alt= thealtattribute (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.
- Author a whitelist of event names. Freeze it. Reject anything else.
- Author the envelope (§3). Every field, sourced from the same place as this doc.
- Author the transport (§4). Dual-fan-out, GET, ~Image().src=. Never throw.
- Author the bot filter + opt-out (§5). Both fail closed on error.
- Author the pure core: element-payload, scroll math, sampling, event-name validation. No DOM here. Test in isolation.
- Author the six listeners (§6.1-6.6) in the browser adapter. Each listener is a pure dispatch — extract the payload, call ~send!=.
- Wire the
[data-track-*]contract (§7). Update page templates so the elements you care about carry ~data-track-id=. - Wire the public API (§9). Compare-and-set-once on ~installed?=.
- Ledger the crossing: bump ~.verify/chain.jsonl= via
~bb scripts/verify-chain add=; verifier
tracker-vN-implementer, subjecttracker-vN. - 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
- retrospective/index.org — build history including tracker's role
- vocabulary-binding/index.org — sponsored-research's dependence on the tracker's event vocabulary
- attribution-audit/spec.org — the consumer of every
sponsored.*andelement.clickevent - beacon/spec.org — payload builder (
beacon.core) shared with the adtech beacon - intent-signals/spec.org — derives intent booleans from tracker
events, most notably
result.dwell(DR1)