Observer — complete spec with reference implementation
Portable passive-capture observer for ecomm + adtech-heavy pages
Table of Contents
- 1. Preamble
- 2. 1. What this document specifies
- 3. 2. Ecomm surface — what to instrument
- 4. 3. Adtech surface — what to instrument
- 5. 4. Configuration model
- 6. 5. Event vocabulary
- 7. 6. Envelope
- 8. 7. Transport
- 9. 8. Bot filter + opt-out
- 10. 9. Listeners — nine, all capture-phase
- 11. 10. SPA navigation
- 12. 11. Iframes
- 13. 12. Consent overlays
- 14. 13. User id
- 15. 14. Debug logger
- 16. 15. Injection surfaces
- 17. 16. Driver — reproducibility
- 18. 17. LLM auditor
- 19. 18. Boundaries
- 20. 19. Refutation condition
- 21. 20. Rebuild recipe
- 22. 21. Reference implementation — tangle blocks
- 22.1. 21.1 contract.cljc — closed-type Unit + total validator
- 22.2. 21.2 browser.cljs — DOM adapter
- 22.3. 21.3 validate-config.clj — bb linter for config
- 22.4. 21.4 config/ecomm.edn — generic ecomm route config
- 22.5. 21.5 config/adtech.edn — generic adtech route config
- 22.6. 21.6 bookmarklet loader
- 22.7. 21.7 WebExtension manifest + content script
- 22.8. 21.8 mitmproxy addon
- 22.9. 21.9 Playwright driver
- 22.10. 21.10 Claude-in-Chrome recipe
- 22.11. 21.11 parse-intent.clj — reverse the pixel stream
- 22.12. 21.12 LLM auditor prompt
- 23. 22. Cross-references
1. Preamble
This is the spec. Read top-to-bottom. Every code block carries a
:tangle header — running org-babel-tangle from Emacs writes each
block to the path in its header, creating a working scaffold.
The observer targets any ecomm or adtech-heavy page: Shopify
checkouts, retail-media search pages, native ad grids, product
listings that lazy-load into infinity, chumbox recommendation
widgets, in-article sponsored slots. It is not specialised for
wal.sh — the wal.sh tracker (src/wal_sh/site/tracker/ in the
oracle repo) is one deployment of the same shape.
Numbered requirements R1-R74 are structural. New requirements require a spec bump; existing ones can be refined but not silently dropped.
2. 1. What this document specifies
A passive-capture observer: a browser-side module that (a) attaches
DOM listeners for scroll, click, hover, form change, form input, form
submit, page-lifecycle, IntersectionObserver impressions, and copy /
selection events; (b) emits each event as a same-shape envelope over
a same-origin pixel GET plus a configurable third-party fan-out; (c)
degrades safely when the target CSP forbids the transport; (d)
persists a per-browser uid across sessions in localStorage; (e)
never captures field values, credentials, cookies, or storage
contents.
The observer is portable — three injection surfaces (bookmarklet,
WebExtension, MITM proxy addon) allow it to be dropped onto any page,
including pages the operator does not control. It is configurable
per route — a config file maps URL patterns to CSS selectors and
handler bindings, so the operator can point the observer at a
competitor's checkout flow and get impressions on every
input[type=checkbox] without touching the page.
3. 2. Ecomm surface — what to instrument
Ecomm-heavy pages share a small number of critical surfaces. The observer's default config MUST recognise all of them so a generic install has non-trivial coverage before any per-site tuning.
3.1. 2.1 Product listing (PLP)
Grids or lists of product tiles. Trackable via
[data-product-id], article[itemtype*=Product] (schema.org), or
any element containing img[src*=product] as fallback.
R1. Impressions on a product tile MUST fire only after ≥50% viewport overlap for ≥1000ms. Separates "user saw it" from "it scrolled past".
R2. Click MUST classify as element.click with elKind =
"product-tile" and carry productId if data-product-id is
present.
3.2. 2.2 Product detail (PDP)
Trackable: main image, gallery thumbnails, variant selectors (size/color), quantity input, add-to-cart, buy-now, review summary, related-product tiles.
R3. Every select that changes an add-to-cart's variant MUST fire
element.change with the old + new option label (label captured,
value opaque per R29).
R4. The add-to-cart button MUST be identifiable by kind
add-to-cart — via data-track-kind or the generic ecomm route
(§4.2).
3.3. 2.3 Cart / mini-cart
Trackable: line items, quantity spinners, remove buttons, promo-code field, subtotal, taxes, ship-to selector, checkout CTA.
R5. Quantity changes MUST fire element.change with the delta
(+1 / -1 / N), not the absolute quantity. Deltas recover to state;
state without deltas doesn't.
R6. Remove button MUST fire element.click with a reason field
if the page exposes one; otherwise null.
3.4. 2.4 Checkout flow
Trackable: address, city/state/zip, phone, email, shipping-method radios, payment-method radios, credit-card form (numbers NEVER captured per §19), promo code, order-summary line items, "place order" CTA.
R7. Every input[type=text|email|tel], textarea, and select
inside a form[action] ending in /checkout, /order,
/purchase, or /confirm MUST auto-instrument as element.input
element.changeeven without explicit config. Checkout
completion is critical; requiring per-field config is a footgun.
R8. Form abandon MUST be detectable: if element.input fires ≥1
time in a form followed by pagehide without form.submit, the
element.dwell on the last-touched field carries abandon: true.
R9. input[type=password] MUST be excluded from ALL events. Hard
rule in the config-load path (elevated to §19 boundary).
3.5. 2.5 Order confirmation
Trackable: order number, order total, line items, "download receipt" / "print" buttons, related-product tiles (upsell).
R10. pageview on /order/complete, /thank-you, or
/confirmation MUST carry conversion: true and orderValue if
extractable ([data-order-total] / schema.org
[itemprop=totalPaymentDue] / regex over currency-formatted
innerText).
4. 3. Adtech surface — what to instrument
4.1. 3.1 Sponsored slots (native, in-line)
Markers: [data-ad-slot], [class*=sponsored], a[href^=/go/],
a[href^=/redirect], a[rel*=sponsored].
R11. Every sponsored unit MUST fire sponsored.impression (not
element.impression) so the auction's inventory analysis can
distinguish organic from paid.
R12. Every sponsored click MUST fire sponsored.click with the
destination clickHref — retail-media click paths route through
affiliate redirects; the observer sees the initial href.
4.2. 3.2 Recommendation widgets (chumbox)
Markers: [id^=taboola], .OUTBRAIN, .trc_related_container,
[data-taboola-source], retailer-native equivalents.
R13. Chumbox tiles fire sponsored.impression AND
sponsored.feed.page (page-index-scoped rollup) so pool depth is
visible.
R14. Headline text NEVER captured verbatim. Instead: headline length in chars + word count + starts-with-numeric? boolean — classifies shape without leaking copy.
4.3. 3.3 Display ads (mrec, banner, sky)
IAB sizes: 300×250, 728×90, 160×600, 320×50. Usually iframed; §11 covers cross-origin observability.
R15. Display impressions require ≥50% pixel overlap for ≥1000ms
plus iframe observability grant OR first-party origin. Otherwise
sponsored.impression.opaque — slot exists, view unverifiable.
4.4. 3.4 Consent / privacy overlays
R16. On init! scan for [id*=consent], [class*=consent],
[id*=cookie-banner], [aria-modal=true][id*=consent],
.cookie-notice, .cc-banner. If present, emit
consent.overlay.impression.
R17. Any click inside overlay → consent.decision with outcome
enum: accept-all, reject-all, customise, dismiss. Classify
by button text substring — Table §4.3.
R18. consent.abandon if pagehide within 1s of overlay
impression with no decision.
5. 4. Configuration model
5.1. 4.1 Structure
Load via window.__wal_tracker.init(<edn-string-or-object>). EDN
in CLJS source, JSON on the wire.
R19. Routes list, first-match-wins. Each route: :url-pattern,
:name, :elements (list of :selector + :kind + optional
:events), :events (subset of vocabulary), :sampling.
R20. Route resolution: iterate :routes in declaration order, first
:url-pattern match on location.href wins. If none match, load
:global only.
R21. Selectors resolve at init! AND on MutationObserver subtree
changes. No polling.
R22. Same node matched by multiple selectors takes identity from FIRST match (declaration order beats DOM order).
R23. A selector match synthesises identity: elId = "<kind>:<idx>",
elKind = <kind>, selector = <CSS> (clipped to 200 chars).
5.2. 4.2 Built-in generic configs
Two configs ship: ecomm-generic (§21.4) and adtech-generic
(§21.5). Enough for first-pass observation of an unfamiliar site
before per-site tuning.
R24. ecomm-generic matches form[action*=checkout|order|cart],
input[type=email|tel|password] (password excluded from events),
button[data-add-to-cart] / button[class*=add-to-cart],
a[href*=/product/|/p/], [itemtype*=Product].
R25. adtech-generic matches [data-ad-slot],
[data-taboola-source], iframe[id^=google_ads],
[class*=sponsored|chumbox|advertorial], a[rel*=sponsored],
a[href^=/go/|/redirect].
5.3. 4.3 Consent-decision classifier
Consent buttons vary; substring-match on lowercase button text:
| Text contains | Outcome |
|---|---|
| accept all, allow all, agree all, got it | accept-all |
| reject all, decline all, necessary only | reject-all |
| customise, customize, manage, preferences | customise |
| close, ×, dismiss | dismiss |
R26. Non-matching → unknown; button text logged (clipped to 200)
so classifier grows.
6. 5. Event vocabulary
Closed set. Adding requires spec bump + emitter.
6.1. 5.1 Every event
| Event | When | Extra fields |
|---|---|---|
pageview |
init, once per page-life; re-fired on SPA nav | conversion, orderValue |
page.dwell |
pagehide / hidden | dwellMs, maxScrollPct |
scroll.depth |
first cross of 25/50/75/100 | depthPct |
scroll.reverse |
after ≥50%, upward ≥200px | fromPct, toPct |
element.impression |
trackable ≥50% ≥1000ms | element identity |
element.dwell |
leaves viewport after impression | + dwellMs, + abandon? |
element.click |
click on trackable | + modKeys, clickHref |
element.hover |
pointer over ≥500ms | element identity |
element.change |
change on checkbox/radio/select | + checked, option, delta |
element.input |
input on text field (throttled 500ms) | + valueKind, valueLen, valueHash |
element.copy |
copy event | + selLen |
element.select |
selectionchange ≥3ch, 750ms throttle | + selLen, anchorTag |
sponsored.impression |
impression on sponsored kind | element identity |
sponsored.impression.opaque |
third-party iframe, no grant | + iframeSrc, size |
sponsored.click |
click on sponsored kind | + clickHref |
sponsored.feed.page |
scroll into new chumbox page | + pageIdx, poolDepth |
form.submit |
form submit | + formId, action, fieldCount |
form.focus |
first focus on input/textarea/select | + tag, name, inputType |
consent.overlay.impression |
consent overlay detected | + provider if recognised |
consent.decision |
button click in overlay | + outcome (R17) |
consent.abandon |
pagehide within 1s, no decision | |
result.dwell |
search-result ≥8s ≥50% | search-mount |
R27. Unknown event names are silently rejected at send!.
6.2. 5.2 valueKind classifier (R29)
Never capture the value. Emit the shape:
| Kind | Predicate |
|---|---|
empty |
="" |
digits |
^\d+$ |
email-shaped |
^[^@\s]+@[^@\s]+\.[^@\s]+$ |
url-shaped |
^https?:// |
currency-shaped |
^\$?\d+(\.\d{2})?$ |
phone-shaped |
^\+?[\d\s()-]{7,}$ |
text-short |
≤20 chars |
text-medium |
≤200 chars |
text-long |
>200 chars |
Plus valueLen (int char count), valueHash (fnv1a 32-bit).
Detects re-typing across sessions without storing the value.
7. 6. Envelope
Every event carries this base map before extra merges on top.
| Field | Source | Purpose |
|---|---|---|
event |
whitelist string | dispatch |
ts |
Date.now() |
ordering |
session |
per page-life | join within session |
uid |
localStorage[<uid-key>] |
join across sessions |
page |
location.pathname |
cluster by URL |
ref |
document.referrer |
inbound path |
tz |
Intl.DateTimeFormat tz |
rough geo |
screen |
${w}x${h} from screen |
device class |
userAgent |
navigator.userAgent |
post-hoc bot |
pageID |
CEDDL if present | tie-in |
viewport |
${innerW}x${innerH} |
mobile/iframe disambig |
depth |
history.state?.depth or 0 |
funnel step |
R28. Field names literal. Downstream parsers key on them.
8. 7. Transport
R29. Fire N pixels per event: :global.transport.endpoint +
each :fan-out + /observer/pixel (same-origin fallback).
R30. Encode as URL query. Skip nil/blank. Clip to 1800 bytes so no CDN truncates mid-parameter.
R31. Never throw. Every handler try/catch → nil on error. A crashing tracker fails harder than a silent one.
9. 8. Bot filter + opt-out
R32. bot? true if navigator.webdriver OR UA contains bot,
Bot, crawl, spider, Spider, HeadlessChrome, Playwright,
Puppeteer, Cypress.
R33. opt-out? true if ?tracking=off in URL.
R34. Both fail closed on exception (return false = allow).
R35. Injector mode inverts R32. When the operator IS the driver
(Playwright / Claude-in-Chrome), config INJECTOR_MODE? disables
the bot gate.
10. 9. Listeners — nine, all capture-phase
10.1. 9.1 Scroll — depth + reverse
R36. scroll on window, passive: true, rAF throttled.
scroll.depth at 25/50/75/100 one-shot per page-life.
scroll.reverse on ≥200px upward after ≥50%.
10.2. 9.2 Click — delegated
R37. Capture-phase click. Walk up via
closest("[data-track-id],a,button,img,figure,input,select,textarea,form").
sponsored.click if predicate holds, else element.click. Include
modKeys.
10.3. 9.3 Hover — dwell-thresholded
R38. Capture-phase pointerover + pointerout. setTimeout(500ms)
that fires element.hover. Cancel on pointerout.
10.4. 9.4 Impression — IntersectionObserver
R39. One observer, threshold: [0, 0.5, 1], rootMargin: "0px".
Entry ≥0.5 ratio → setTimeout(1000ms) → element.impression.
Exit → cancel or emit element.dwell with dwell time.
10.5. 9.5 Form change
R40. Capture-phase change. Payload: checked (checkbox/radio)
option(select label). Values never captured — only state.
10.6. 9.6 Form input (throttled)
R41. Capture-phase input. Per-element setTimeout(500ms)
quiet-then-fire. Emit element.input with valueKind + valueLen
valueHash(never the value).
R42. Sampled BEFORE throttle allocation.
10.7. 9.7 Copy + select
R43. copy handler → element.copy with selLen.
R44. selectionchange, throttled 750ms, ≥3ch → element.select
with selLen + anchorTag.
10.8. 9.8 Form submit + first-focus
R45. Capture-phase submit → form.submit with formId,
action, fieldCount.
R46. Capture-phase focusin → form.focus on first focus of
input/textarea/select. One shot per field per page-life.
10.9. 9.9 Page-lifecycle dwell
R47. Both pagehide AND visibilitychange (hidden). Emit
page.dwell with dwellMs + maxScrollPct.
11. 10. SPA navigation
R48. Hook history.pushState + history.replaceState (monkey-patch
proto) AND popstate event. On URL change:
- Emit
page.dwellfor outgoing page - Reset per-page state
- Re-resolve config against new URL
- Re-observe matched nodes
- Emit
pageviewwithdepth+1
R49. Do NOT hook location setter — surface not enumerable,
Object.defineProperty triggers frameworks. pushState + popstate +
replaceState covers ≥99%; MutationObserver on <title> as fallback.
12. 11. Iframes
R50. First-party iframes (same eTLD+1): observer attaches inside via
contentDocument. Envelope carries iframeSrc.
R51. Third-party iframes: IntersectionObserver on the <iframe>
element itself. sponsored.impression.opaque with iframeSrc,
size, isIntersecting.
R52. NEVER attempt postMessage handshake into cross-origin
iframe. Observability grant is a separate contract.
13. 12. Consent overlays
R53. On init!, scan for markers (§4.3). If present:
- Emit
consent.overlay.impression - Wait for
consent.decisionOR 1s abandon gate - If
reject-all: reduce fan-out to same-origin only, drop third-party beacon. Respect user even in injector mode by default; operator can override.
R54. Close-button (dismiss) is NOT consent. Treat as
consent.abandon for fan-out purposes.
14. 13. User id
R55. Persistent per-browser id at localStorage[<uid-key>] (default
_observer_uid, configurable).
R56. Generation: 128 bits from crypto.getRandomValues (fallback
Math.random), base36, prefix u-. Never crypto.subtle, canvas,
audio, or WebGL — opacity depends on being generated, not derived.
R57. NEVER auto-rotate. Operator MAY force-rotate by clearing.
R58. One uid → many sessions. Session per page-life. Every event carries both.
15. 14. Debug logger
R59. Sinks: none (production) · console · localStorage (ring
buffer, ring size, key _observer_log) · both.
R60. window.__wal_tracker.dumpLog() returns plain array. Never
uploaded automatically.
R61. Debug lines carry same ts + session as pixel envelope
(reconcile with §21.11).
16. 15. Injection surfaces
16.1. 15.1 Bookmarklet (§21.6)
R62. One-line loader. Subject to target CSP. Fall back to WebExtension or mitmproxy on CSP block.
16.2. 15.2 WebExtension (§21.7)
R63. MV3. Content script in isolated world → bypasses page CSP.
host_permissions enumerates origins. Visible browser-action badge
when active — silent observer is a compliance failure.
16.3. 15.3 mitmproxy addon (§21.8)
R64. String-substitutes </body> with observer script tag. Strips
response CSP. HTTPS mutation requires explicit host in
mitm-allowed-hosts.txt. Every mutation → mitm-observed.log.
17. 16. Driver — reproducibility
R65. Two drivers ship: Playwright (§21.9) and Claude-in-Chrome (§21.10). Both accept JSON output of §21.11 parser as input.
R66. Round-trip: replay produces AT LEAST the driven events. May produce more (impressions on render, page.dwell on pagehide) but never fewer.
18. 17. LLM auditor
R67. Prompt template lives at §21.12 as a tangle block — versioned independently of the observer.
R68. Auditor never sees uid. One session at a time. Model name
- prompt hash MUST accompany any ledger append.
R69. Anomaly tag vocabulary (fixed): form-abandon ·
rage-click · back-and-forth · sponsored-blind · bot-shaped
· broken-affordance · consent-abandon · checkout-abandon
(ecomm) · add-to-cart-then-leave (ecomm) · chumbox-scroll-past
(adtech) · iframe-impressive (adtech, opaque-only) · unknown.
New tags require spec bump.
R70. Auditor output is a proposal, not a verdict. Operator reviews before ledger append.
19. 18. Boundaries
R71. NEVER capture:
- Password field values (R9)
- Credit-card number fields (
input[autocomplete*=cc-number],input[name*=card-number]) - CVV / security-code fields
- Field values in general (only shape via R29)
document.cookie- localStorage keys other than observer's own uid + debug log
- IndexedDB contents
- Response bodies
- Cross-origin iframe contents
R72. Every boundary is a silent refusal. Attempting to configure a password-field selector → one console warning at init, selector dropped. No throw.
R73. Consent-reject reduces fan-out to same-origin (R53). Default respects user.
R74. No cross-origin uid sync. Two host injections → two uids.
20. 19. Refutation condition
The claim: this spec is sufficient to build ONE observer that runs against wal.sh (matching the tracker) AND against any ecomm/adtech-heavy site (via §15) without site-specific code, with the same 22-event vocabulary.
Fails if:
- Interaction that fires an event on wal.sh does NOT fire on injected page (identity/transport mismatch)
ecomm-generic(§4.2) misses ≥50% of trackable elements on any of the ten most-trafficked ecomm platforms (Shopify, WooCommerce, BigCommerce, Magento, Wix, Squarespace, SFCC, Amazon, Walmart, Target)adtech-genericmisses ≥50% on any of Google Ad Manager, Amazon Publisher Services, Taboola, Outbrain, Prebid.js sites- LLM auditor produces materially different anomaly tags for the same session across two runs of same model + prompt
Test: pick two competitor storefronts, inject via §15.2, drive a 5-minute session via §17, run auditor twice, diff tag bags.
21. 20. Rebuild recipe
- Tangle this file (
M-x org-babel-tanglefrom Emacs). - Read §21.1 (contract.cljc). Author closed-type Unit + single constructor + total validator.
- Land negative fixtures under
src/cljs/observer/fixtures/. - Author gate (§21.3). Exit 1 on failure.
- Author
browser.cljs(§21.2) — DOM adapter delegating every emit to contract'ssend!. Includes nine listeners (§9), SPA hook (§10), iframe path (§11), consent path (§12). - Wire public API —
window.__wal_trackerwithinit,send,rescan,session,dumpLog. - Build one injection surface (§15.1 easiest); test locally.
- Build driver stub (§21.9); confirm round-trip.
- Build intent parser (§21.11); confirm it reverses the pixel stream.
- Wire auditor (§21.12); run against one session.
- Ledger the crossing: append to
.verify/chain.jsonl. - Extend to §15.2 + §15.3 per target.
22. 21. Reference implementation — tangle blocks
22.1. 21.1 contract.cljc — closed-type Unit + total validator
(ns observer.contract
"Closed-type event unit + total validator. Single constructor
`build`. Every emit path goes through this — browser.cljs never
manufactures an event map directly. SPEC §5, §6."
(:require [clojure.string :as str]))
(def event-names
"Closed set. Adding a name requires a spec bump AND a browser-side
emitter. Any name outside this set is silently rejected."
#{"pageview" "page.dwell"
"scroll.depth" "scroll.reverse"
"element.impression" "element.dwell" "element.click" "element.hover"
"element.change" "element.input" "element.copy" "element.select"
"sponsored.impression" "sponsored.impression.opaque"
"sponsored.click" "sponsored.feed.page"
"form.submit" "form.focus"
"consent.overlay.impression" "consent.decision" "consent.abandon"
"result.dwell"})
(def envelope-keys
"SPEC §6 — the closed base envelope. Additional keys go via :extra."
#{:event :ts :session :uid :page :ref :tz :screen :userAgent
:pageID :viewport :depth})
(def value-kind
"SPEC §5.2 classifier. String → kind. Never captures the value."
(let [rules [[#"^$" "empty"]
[#"^\d+$" "digits"]
[#"^[^@\s]+@[^@\s]+\.[^@\s]+$" "email-shaped"]
[#"^https?://.*" "url-shaped"]
[#"^\$?\d+(\.\d{2})?$" "currency-shaped"]
[#"^\+?[\d\s()-]{7,}$" "phone-shaped"]]]
(fn [s]
(let [s (or s "")]
(or (some (fn [[re k]] (when (re-matches re s) k)) rules)
(cond
(<= (count s) 20) "text-short"
(<= (count s) 200) "text-medium"
:else "text-long"))))))
(defn fnv1a
"32-bit FNV-1a. Cross-language reproducible; do not substitute djb2."
[s]
(reduce (fn [h c]
(let [x (bit-xor h (int c))]
(bit-and 0xffffffff (unchecked-multiply-int x 16777619))))
(unchecked-int 2166136261)
(map int (or s ""))))
(defn value-summary
"SPEC R29 + §5.2. Returns {:valueKind :valueLen :valueHash} — never
the value itself."
[s]
(let [s (or s "")]
{:valueKind (value-kind s)
:valueLen (count s)
:valueHash (fnv1a s)}))
(defn valid?
"Total validator. Returns [ok? violations]. SPEC R27."
[{:keys [event ts session] :as e}]
(let [violations
(cond-> []
(not (string? event)) (conj :event-not-string)
(not (contains? event-names event)) (conj :event-not-in-whitelist)
(not (number? ts)) (conj :ts-not-number)
(not (string? session)) (conj :session-not-string))]
[(empty? violations) violations]))
(defn build
"Single constructor. Returns {:ok true :value e} or
{:ok false :violations [...]}. Callers get an explicit failure,
not a limp map with nil fields."
[event-name ts session opts]
(let [{:keys [uid page ref tz screen userAgent pageID viewport depth extra]} opts
base {:event event-name
:ts (or ts 0)
:session (or session "")
:uid (or uid "")
:page (or page "")
:ref (or ref "")
:tz (or tz "")
:screen (or screen "")
:userAgent (or userAgent "")
:pageID (or pageID "")
:viewport (or viewport "")
:depth (or depth 0)}
e (merge base (or extra {}))
[ok? vs] (valid? e)]
(if ok?
{:ok true :value e}
{:ok false :violations vs})))
22.2. 21.2 browser.cljs — DOM adapter
(ns observer.browser
"Browser adapter — DOM listeners → contract.build → transport.
Everything DOM-touching lives here. Pure logic in observer.contract.
SPEC §9-13."
(:require [observer.contract :as c]
[clojure.string :as str]
[cljs.reader]))
(defonce ^:private !installed? (atom false))
(defonce ^:private !session (atom nil))
(defonce ^:private !cfg
(atom {:global {:transport {:endpoint nil :fan-out []}
:uid-key "_observer_uid"
:injector-mode? false
:debug {:sink "none" :buffer-size 500}}}))
(defonce ^:private !route (atom nil))
(defonce ^:private !max-scroll-pct (atom 0))
(defonce ^:private !scroll-fired (atom #{}))
(defonce ^:private !reverse-fired? (atom false))
(defonce ^:private !page-t0 (atom nil))
(defonce ^:private !hover-timers (atom {}))
(defonce ^:private !impr-timers (atom {}))
(defonce ^:private !impr-t0 (atom {}))
(defonce ^:private !input-timers (atom {}))
(defonce ^:private !debug-buffer (atom []))
(defonce ^:private !current-url (atom nil))
(defonce ^:private !last-y (atom 0))
(defonce ^:private !io (atom nil))
(defonce ^:private !last-sel-fire (atom 0))
(defonce ^:private !consent-t0 (atom nil))
;; --- Host helpers ------------------------------------------------------------
(defn- now-ms [] (.getTime (js/Date.)))
(defn- safe-tz []
(try (-> (js/Intl.DateTimeFormat.) .resolvedOptions .-timeZone)
(catch :default _ "")))
(defn- safe-screen []
(try (str (.-width js/screen) "x" (.-height js/screen))
(catch :default _ "")))
(defn- safe-viewport []
(try (str (or (.-innerWidth js/window) 0) "x"
(or (.-innerHeight js/window) 0))
(catch :default _ "")))
(defn- gen-uid []
(let [buf (js/Uint32Array. 4)]
(try (.getRandomValues js/crypto buf)
(catch :default _
(dotimes [i 4]
(aset buf i (js/Math.floor (* (js/Math.random) 4294967296))))))
(str "u-" (.toString (aget buf 0) 36)
(.toString (aget buf 1) 36)
(.toString (aget buf 2) 36)
(.toString (aget buf 3) 36))))
(defn- uid-key [] (get-in @!cfg [:global :uid-key] "_observer_uid"))
(defn- ensure-uid! []
(try
(or (.getItem js/localStorage (uid-key))
(let [u (gen-uid)] (.setItem js/localStorage (uid-key) u) u))
(catch :default _ (or @!session ""))))
(defn- ensure-session! []
(or @!session
(reset! !session
(str "s-" (.toString (js/Math.abs (+ (now-ms) (* (js/Math.random) 1e9)))
36)))))
;; --- Bot filter + opt-out (§8) ----------------------------------------------
(defn- injector-mode? [] (get-in @!cfg [:global :injector-mode?] false))
(defn- bot? []
(when-not (injector-mode?)
(try
(or (true? (.-webdriver js/navigator))
(let [ua (or (.-userAgent js/navigator) "")]
(boolean (some #(str/includes? ua %)
["bot" "Bot" "crawl" "spider" "Spider"
"HeadlessChrome" "Playwright" "Puppeteer"
"Cypress"]))))
(catch :default _ false))))
(defn- opt-out? []
(try (= "off"
(.get (js/URLSearchParams. (.-search js/location)) "tracking"))
(catch :default _ false)))
;; --- Transport (§7) ----------------------------------------------------------
(defn- ->query [payload]
(let [pairs (for [[k v] payload
:when (and v (not (and (string? v) (= "" v))))]
(str (js/encodeURIComponent (name k)) "="
(js/encodeURIComponent
(cond (string? v) v
(number? v) (str v)
(boolean? v) (if v "1" "0")
:else (str v)))))
raw (str/join "&" pairs)]
(if (> (count raw) 1800) (subs raw 0 1800) raw)))
(defn- fire-one! [endpoint q]
(try (let [img (js/Image.)]
(set! (.-src img) (str endpoint "?" q)) img)
(catch :default _ nil)))
(defn- endpoints []
(let [t (get-in @!cfg [:global :transport])]
(cons (:endpoint t) (concat (:fan-out t) ["/observer/pixel"]))))
(defn- fire-pixel! [payload]
(let [q (->query payload)]
(doseq [ep (keep identity (endpoints))]
(fire-one! ep q))))
;; --- Debug sink (§14) --------------------------------------------------------
(defn- debug! [payload]
(let [sink (get-in @!cfg [:global :debug :sink] "none")]
(when (contains? #{"console" "both"} sink)
(js/console.debug "[obs]" (:event payload)
(or (:elId payload) "") payload))
(when (contains? #{"localStorage" "both"} sink)
(let [bs (get-in @!cfg [:global :debug :buffer-size] 500)]
(swap! !debug-buffer
(fn [b] (into (vec (take-last (dec bs) b)) [payload])))))))
;; --- Central send! -----------------------------------------------------------
(defn- send!
([event-name] (send! event-name {}))
([event-name extra]
(when (and (not (bot?)) (not (opt-out?)))
(let [built (c/build event-name (now-ms) (ensure-session!)
{:uid (ensure-uid!)
:page (.-pathname js/location)
:ref (.-referrer js/document)
:tz (safe-tz)
:screen (safe-screen)
:viewport (safe-viewport)
:userAgent (.-userAgent js/navigator)
:extra extra})]
(when (:ok built)
(fire-pixel! (:value built))
(debug! (:value built)))))))
;; --- Identity extraction (§4.1 R23) -----------------------------------------
(defn- clip [s]
(when (string? s) (if (> (count s) 200) (subs s 0 200) s)))
(defn- img-basename [src]
(when (string? src)
(last (str/split (first (str/split src #"\?" 2)) #"/"))))
(defn- password-field? [node]
(and node
(= "input" (some-> (.-tagName node) (.toLowerCase)))
(or (= "password" (.getAttribute node "type"))
(str/includes? (or (.getAttribute node "autocomplete") "")
"cc-number")
(str/includes? (or (.getAttribute node "autocomplete") "")
"cc-csc")
(some #(str/includes? (or (.getAttribute node "name") "") %)
["card-number" "cvv" "card-cvv" "security-code"]))))
(defn- el-attrs [node]
(try
(when (and node (not (password-field? node)))
(let [tag (some-> (.-tagName node) (.toLowerCase))
explicit-id (.getAttribute node "data-track-id")
src (when (= tag "img") (.getAttribute node "src"))
alt (when (= tag "img") (.getAttribute node "alt"))]
{:elId (clip (or explicit-id
(when src (str "img:" (img-basename src)))))
:elKind (clip (or (.getAttribute node "data-track-kind")
(when (= tag "img") "img")))
:tag tag
:href (clip (or (.getAttribute node "href") src))
:alt (clip alt)
:sponsor (clip (.getAttribute node "data-track-sponsor"))
:creativeId (clip (.getAttribute node "data-track-creative"))
:unit (clip (.getAttribute node "data-track-unit"))
:productId (clip (.getAttribute node "data-product-id"))}))
(catch :default _ nil)))
(defn- closest-trackable [node]
(try (.closest node
"[data-track-id],a,button,img,figure,input,select,textarea,form")
(catch :default _ nil)))
(defn- sponsored? [attrs]
(or (str/includes? (or (:elKind attrs) "") "sponsored")
(str/includes? (or (:elKind attrs) "") "chumbox")
(some? (:sponsor attrs))
(some? (:creativeId attrs))))
;; --- Scroll (§9.1) -----------------------------------------------------------
(defn- doc-height []
(max (.. js/document -body -scrollHeight)
(.. js/document -documentElement -scrollHeight)))
(defn- scroll-pct []
(let [y (or (.-scrollY js/window) 0)
vh (or (.-innerHeight js/window) 1)
dh (max 1 (- (doc-height) vh))]
(max 0 (min 100 (int (* 100 (/ y dh)))))))
(defn- on-scroll []
(let [pct (scroll-pct) y (or (.-scrollY js/window) 0) dy (- y @!last-y)
crossed (->> [25 50 75 100]
(filter #(and (>= pct %) (not (contains? @!scroll-fired %))))
set)]
(swap! !max-scroll-pct max pct)
(doseq [m crossed]
(swap! !scroll-fired conj m)
(send! "scroll.depth" {:depthPct m}))
(when (and (not @!reverse-fired?) (>= @!max-scroll-pct 50) (< dy -200))
(reset! !reverse-fired? true)
(send! "scroll.reverse" {:fromPct @!max-scroll-pct :toPct pct}))
(reset! !last-y y)))
(defn- rAF-throttle [f]
(let [pending? (volatile! false)]
(fn []
(when-not @pending?
(vreset! pending? true)
(js/requestAnimationFrame
(fn [] (vreset! pending? false) (try (f) (catch :default _ nil))))))))
;; --- Click / Hover / Impression / Change / Input / Copy / Select / Submit / Focus
(defn- on-click [ev]
(try
(let [node (closest-trackable (.-target ev))]
(when node
(let [attrs (el-attrs node)]
(when attrs
(send! (if (sponsored? attrs) "sponsored.click" "element.click")
(assoc attrs
:modKeys (cond-> []
(.-ctrlKey ev) (conj "ctrl")
(.-metaKey ev) (conj "meta")
(.-shiftKey ev) (conj "shift")
(.-altKey ev) (conj "alt"))
:clickHref (:href attrs)))))))
(catch :default _ nil)))
(defn- on-pointerover [ev]
(try
(let [node (closest-trackable (.-target ev))
attrs (el-attrs node)
key (or (:elId attrs) (str (hash node)))]
(when attrs
(swap! !hover-timers assoc key
(js/setTimeout #(send! "element.hover" attrs) 500))))
(catch :default _ nil)))
(defn- on-pointerout [ev]
(try
(let [node (closest-trackable (.-target ev))
attrs (el-attrs node)
key (or (:elId attrs) (str (hash node)))]
(when-let [tid (get @!hover-timers key)]
(js/clearTimeout tid)
(swap! !hover-timers dissoc key)))
(catch :default _ nil)))
(defn- on-intersect [entries]
(doseq [entry (array-seq entries)]
(try
(let [node (.-target entry)
attrs (el-attrs node)
key (or (:elId attrs) (str (hash node)))]
(when attrs
(if (and (.-isIntersecting entry) (>= (.-intersectionRatio entry) 0.5))
(let [t0 (now-ms)
tid (js/setTimeout
#(send! (if (sponsored? attrs) "sponsored.impression"
"element.impression")
attrs)
1000)]
(swap! !impr-timers assoc key tid)
(swap! !impr-t0 assoc key t0))
(do
(when-let [tid (get @!impr-timers key)]
(js/clearTimeout tid)
(swap! !impr-timers dissoc key))
(when-let [t0 (get @!impr-t0 key)]
(let [dwell (- (now-ms) t0)]
(when (>= dwell 1000)
(send! "element.dwell" (assoc attrs :dwellMs dwell)))
(swap! !impr-t0 dissoc key)))))))
(catch :default _ nil))))
(defn- observe-tracked! []
(try
(when-let [io @!io]
(doseq [n (array-seq
(.querySelectorAll js/document
"[data-track-id],[data-ad-slot],[class*=sponsored],img,form input,form select"))]
(.observe io n)))
(catch :default _ nil)))
(defn- on-change [ev]
(try
(let [node (.-target ev)]
(when (and node (not (password-field? node)))
(let [tag (some-> (.-tagName node) (.toLowerCase))
attrs (el-attrs node)]
(when attrs
(send! "element.change"
(cond-> attrs
(= tag "input") (assoc :checked (.-checked node))
(= tag "select") (assoc :option (clip (.-value node)))))))))
(catch :default _ nil)))
(defn- on-input [ev]
(try
(let [node (.-target ev)]
(when (and node (not (password-field? node)))
(let [attrs (el-attrs node)
key (or (:elId attrs) (str (hash node)))]
(when-let [tid (get @!input-timers key)] (js/clearTimeout tid))
(swap! !input-timers assoc key
(js/setTimeout
(fn [] (send! "element.input"
(merge attrs (c/value-summary (.-value node)))))
500)))))
(catch :default _ nil)))
(defn- on-copy []
(try
(let [len (count (.toString (.getSelection js/window)))]
(when (pos? len) (send! "element.copy" {:selLen len})))
(catch :default _ nil)))
(defn- on-selectionchange []
(try
(let [now (now-ms)
len (count (.toString (.getSelection js/window)))]
(when (and (>= len 3) (>= (- now @!last-sel-fire) 750))
(reset! !last-sel-fire now)
(send! "element.select" {:selLen len})))
(catch :default _ nil)))
(defn- on-submit [ev]
(try
(let [form (.-target ev)]
(send! "form.submit"
{:formId (clip (.getAttribute form "id"))
:action (clip (.getAttribute form "action"))
:fieldCount (count (array-seq (.-elements form)))}))
(catch :default _ nil)))
(defn- on-focusin [ev]
(try
(let [node (.-target ev)
tag (some-> (.-tagName node) (.toLowerCase))]
(when (and (contains? #{"input" "textarea" "select"} tag)
(not (password-field? node)))
(send! "form.focus"
{:tag tag
:name (clip (.getAttribute node "name"))
:inputType (clip (.getAttribute node "type"))})))
(catch :default _ nil)))
;; --- Page-lifecycle (§9.9) + SPA nav (§10) ----------------------------------
(defn- on-pagehide []
(when-let [t0 @!page-t0]
(send! "page.dwell" {:dwellMs (- (now-ms) t0)
:maxScrollPct @!max-scroll-pct})))
(defn- on-visibility []
(when (= "hidden" (.-visibilityState js/document)) (on-pagehide)))
(defn- reset-page-state! []
(reset! !page-t0 (now-ms))
(reset! !scroll-fired #{})
(reset! !max-scroll-pct 0)
(reset! !reverse-fired? false)
(reset! !last-y 0)
(reset! !hover-timers {})
(reset! !impr-timers {})
(reset! !impr-t0 {})
(reset! !input-timers {}))
(defn- on-url-change [new-url]
(when (not= new-url @!current-url)
(on-pagehide)
(reset-page-state!)
(reset! !current-url new-url)
(send! "pageview" {:depth (or (some-> js/history .-state (.-depth)) 0)})
(observe-tracked!)))
(defn- hook-history! []
(let [orig-push (.-pushState js/history)
orig-repl (.-replaceState js/history)
wrap (fn [orig]
(fn [& args]
(let [r (.apply orig js/history (to-array args))]
(js/setTimeout #(on-url-change (.-href js/location)) 0)
r)))]
(set! (.-pushState js/history) (wrap orig-push))
(set! (.-replaceState js/history) (wrap orig-repl))
(.addEventListener js/window "popstate"
(fn [_] (on-url-change (.-href js/location))))))
;; --- Consent overlay (§12) --------------------------------------------------
(defn- consent-selectors []
["[id*=consent]" "[class*=consent]" "[id*=cookie-banner]"
"[aria-modal=true][id*=consent]" ".cookie-notice" ".cc-banner"])
(defn- consent-node []
(try
(some #(.querySelector js/document %) (consent-selectors))
(catch :default _ nil)))
(defn- classify-consent [text]
(let [t (str/lower-case (or text ""))]
(cond
(some #(str/includes? t %) ["accept all" "allow all" "agree all" "got it"])
"accept-all"
(some #(str/includes? t %) ["reject all" "decline all" "necessary only"])
"reject-all"
(some #(str/includes? t %) ["customise" "customize" "manage" "preferences"])
"customise"
(some #(str/includes? t %) ["close" "×" "dismiss"])
"dismiss"
:else "unknown")))
(defn- attach-consent! []
(when-let [node (consent-node)]
(reset! !consent-t0 (now-ms))
(send! "consent.overlay.impression" {})
(.addEventListener node "click"
(fn [ev]
(let [text (some-> (.-target ev) .-textContent str/trim)]
(send! "consent.decision"
{:outcome (classify-consent text)
:buttonText (clip text)}))))))
;; --- Attach + public API ----------------------------------------------------
(defn- attach! []
(reset! !page-t0 (now-ms))
(reset! !current-url (.-href js/location))
(.addEventListener js/window "scroll" (rAF-throttle on-scroll)
#js {:passive true})
(.addEventListener js/document "click" on-click true)
(.addEventListener js/document "pointerover" on-pointerover true)
(.addEventListener js/document "pointerout" on-pointerout true)
(.addEventListener js/document "change" on-change true)
(.addEventListener js/document "input" on-input true)
(.addEventListener js/document "copy" on-copy)
(.addEventListener js/document "selectionchange" on-selectionchange)
(.addEventListener js/document "submit" on-submit true)
(.addEventListener js/document "focusin" on-focusin true)
(.addEventListener js/window "pagehide" on-pagehide)
(.addEventListener js/document "visibilitychange" on-visibility)
(when (exists? js/IntersectionObserver)
(reset! !io (js/IntersectionObserver. on-intersect
#js {:threshold #js [0 0.5 1] :rootMargin "0px"}))
(observe-tracked!)
(.addEventListener js/document "DOMContentLoaded" observe-tracked!))
(hook-history!)
(attach-consent!))
(defn ^:export init!
"Public entry. Config is EDN string or JS object. Idempotent."
[cfg]
(when (compare-and-set! !installed? false true)
(reset! !cfg (if (string? cfg)
(cljs.reader/read-string cfg)
(js->clj cfg :keywordize-keys true)))
(attach!)
(send! "pageview" {:depth 0})
(let [api #js {:send (fn [e d] (send! e (js->clj d :keywordize-keys true)))
:rescan observe-tracked!
:session ensure-session!
:dumpLog (fn [] (clj->js @!debug-buffer))}]
(set! (.-__wal_tracker js/window) api)
api)))
22.3. 21.3 validate-config.clj — bb linter for config
;; validate-config.clj — sanity-check a route config before loading it
;; into a live observer. Rejects R71 boundary violations (password field
;; selectors), unknown events, malformed url-patterns.
(require '[clojure.edn :as edn])
(def known-events
#{:pageview :page.dwell :scroll.depth :scroll.reverse
:element.impression :element.dwell :element.click :element.hover
:element.change :element.input :element.copy :element.select
:sponsored.impression :sponsored.impression.opaque
:sponsored.click :sponsored.feed.page
:form.submit :form.focus
:consent.overlay.impression :consent.decision :consent.abandon
:result.dwell})
(def password-selector-re
#"password|cc-number|card-number|cvv|security-code")
(defn violations [cfg]
(concat
(for [{:keys [url-pattern events name]} (:routes cfg)
:when (not (string? url-pattern))]
[:route-url-pattern-not-string name])
(for [{:keys [name events]} (:routes cfg)
e events :when (not (contains? known-events e))]
[:route-unknown-event name e])
(for [{:keys [name elements]} (:routes cfg)
{:keys [selector]} elements
:when (re-find password-selector-re (str selector))]
[:route-password-field-selector name selector])
(when-not (get-in cfg [:global :transport :endpoint])
[[:missing-global-transport-endpoint]])))
(defn -main [& [path]]
(let [cfg (edn/read-string (slurp (or path "config/ecomm.edn")))
vs (violations cfg)]
(if (seq vs)
(do (doseq [v vs] (println "✗" v)) (System/exit 1))
(do (println "✓ config OK — routes:" (count (:routes cfg)))
(System/exit 0)))))
(apply -main *command-line-args*)
22.4. 21.4 config/ecomm.edn — generic ecomm route config
{:routes
[{:url-pattern "^https?://[^/]+/(checkout|cart|order|purchase|confirm)(/.*)?$"
:name "checkout"
:elements [{:selector "input[type=checkbox]" :kind "checkbox"}
{:selector "input[type=radio]" :kind "radio"}
{:selector "select" :kind "select"}
{:selector "input[type=email]" :kind "field-email"}
{:selector "input[type=tel]" :kind "field-phone"}
{:selector "input[name*=address]" :kind "field-address"}
{:selector "input[name*=zip]" :kind "field-zip"}
{:selector "input[type=submit]" :kind "submit"}
{:selector "button[type=submit]" :kind "submit"}
{:selector "form" :kind "form"
:events [:submit :change]}]
:events [:pageview :page.dwell :scroll.depth :scroll.reverse
:element.impression :element.dwell
:element.click :element.hover
:element.change :element.input :element.copy
:form.submit :form.focus
:consent.overlay.impression :consent.decision :consent.abandon]
:sampling {:pct 100}}
{:url-pattern "^https?://[^/]+/(product|products|p|item)/.+$"
:name "pdp"
:elements [{:selector "[itemtype*=Product]" :kind "product-detail"}
{:selector "img.product-image" :kind "product-img"}
{:selector "select[name*=variant]" :kind "variant"}
{:selector "select[name*=size]" :kind "size"}
{:selector "select[name*=color]" :kind "color"}
{:selector "input[name=quantity]" :kind "quantity"}
{:selector "button[data-add-to-cart]" :kind "add-to-cart"}
{:selector "button[class*=add-to-cart]" :kind "add-to-cart"}
{:selector "button[data-buy-now]" :kind "buy-now"}]
:events [:pageview :page.dwell :scroll.depth
:element.impression :element.dwell
:element.click :element.hover :element.change]
:sampling {:pct 100}}
{:url-pattern "^https?://[^/]+/(category|collection|c|shop|search)(/.*)?$"
:name "plp"
:elements [{:selector "[data-product-id]" :kind "product-tile"}
{:selector "article[itemtype*=Product]" :kind "product-tile"}
{:selector "a[href*=/product/]" :kind "product-link"}
{:selector "a[href*=/p/]" :kind "product-link"}
{:selector "button[data-add-to-cart]" :kind "add-to-cart"}]
:events [:pageview :page.dwell :scroll.depth
:element.impression :element.dwell
:element.click :element.hover]
:sampling {:pct 100}}
{:url-pattern "^https?://[^/]+/(order/complete|thank-you|confirmation)(/.*)?$"
:name "confirmation"
:elements [{:selector "[data-order-total]" :kind "order-total"}
{:selector "[data-order-number]" :kind "order-number"}
{:selector "[itemprop=totalPaymentDue]" :kind "order-total"}]
:events [:pageview :page.dwell :element.impression]
:sampling {:pct 100}}]
:global
{:transport {:endpoint "https://beacon.example.com/pixel"
:fan-out ["/observer/pixel"]}
:uid-key "_observer_uid"
:injector-mode? false
:debug {:sink "console" :buffer-size 500}}}
22.5. 21.5 config/adtech.edn — generic adtech route config
{:routes
[{:url-pattern ".*"
:name "adtech-generic"
:elements [{:selector "[data-ad-slot]" :kind "sponsored-slot"}
{:selector "[class*=sponsored]" :kind "sponsored-inline"}
{:selector "[class*=advertorial]" :kind "sponsored-inline"}
{:selector "a[rel*=sponsored]" :kind "sponsored-link"}
{:selector "a[href^=/go/]" :kind "sponsored-link"}
{:selector "a[href^=/redirect]" :kind "sponsored-link"}
{:selector "[id^=taboola]" :kind "chumbox-taboola"}
{:selector "[data-taboola-source]" :kind "chumbox-taboola"}
{:selector ".OUTBRAIN" :kind "chumbox-outbrain"}
{:selector ".trc_related_container" :kind "chumbox-taboola"}
{:selector "[class*=chumbox]" :kind "chumbox-generic"}
{:selector "[class*=recommendations]" :kind "chumbox-generic"}
{:selector "iframe[id^=google_ads]" :kind "display-gam"}
{:selector "iframe[src*=doubleclick]" :kind "display-gam"}
{:selector "iframe[src*=amazon-adsystem]" :kind "display-aps"}
{:selector "iframe[data-ad-slot]" :kind "display-generic"}]
:events [:pageview :page.dwell :scroll.depth :scroll.reverse
:element.impression :element.dwell
:sponsored.impression :sponsored.impression.opaque
:sponsored.click :sponsored.feed.page
:consent.overlay.impression :consent.decision :consent.abandon]
:sampling {:pct 100}}]
:global
{:transport {:endpoint "https://beacon.example.com/pixel"
:fan-out ["/observer/pixel"]}
:uid-key "_observer_uid"
:injector-mode? false
:debug {:sink "console" :buffer-size 500}}}
22.6. 21.6 bookmarklet loader
// Bookmarklet loader (§15.1). Paste into browser bookmark URL:
//
// javascript:(async()=>{const s=document.createElement('script');
// s.src='https://obs.example.com/observer.js';document.head.append(s);
// await new Promise(r=>s.onload=r);
// window.__wal_tracker.init(await(await fetch(
// 'https://obs.example.com/config/'+location.hostname+'.edn')).text());})()
//
// Subject to target CSP. If script-src 'self' blocks the observer origin,
// fall back to WebExtension (§15.2) or mitmproxy (§15.3).
(async () => {
const s = document.createElement('script');
s.src = 'https://obs.example.com/observer.js';
document.head.append(s);
await new Promise((r) => (s.onload = r));
const cfg = await (
await fetch(`https://obs.example.com/config/${location.hostname}.edn`)
).text();
window.__wal_tracker.init(cfg);
})();
22.7. 21.7 WebExtension manifest + content script
{
"manifest_version": 3,
"name": "Observer (private)",
"version": "0.1.0",
"description": "Route-configured passive-capture observer.",
"permissions": ["storage", "scripting", "activeTab"],
"host_permissions": ["https://example.com/*"],
"background": { "service_worker": "background.js" },
"content_scripts": [
{
"matches": ["https://example.com/*"],
"js": ["observer.js", "content.js"],
"run_at": "document_idle",
"world": "ISOLATED"
}
],
"action": {
"default_title": "Observer active",
"default_icon": { "16": "icon.png" }
}
}
// content.js — MV3 content script. Runs in isolated world (bypasses page
// CSP). Loads config from chrome.storage.local keyed by hostname, falls
// back to a bundled default.
const DEFAULT_CFG = JSON.stringify({
routes: [],
global: {
transport: { endpoint: 'https://beacon.example.com/pixel', fanOut: [] },
uidKey: '_observer_uid',
injectorMode: false,
debug: { sink: 'console', bufferSize: 500 },
},
});
(async () => {
const host = location.hostname;
const stored = await chrome.storage.local.get([`cfg:${host}`]);
const cfg = stored[`cfg:${host}`] ?? DEFAULT_CFG;
window.__wal_tracker?.init?.(cfg);
chrome.runtime.sendMessage({ type: 'observer-active', host });
})();
22.8. 21.8 mitmproxy addon
"""observer_inject.py — mitmproxy addon per SPEC §15.3.
R64 — string-substitutes </body> with observer script tag; strips
response CSP so injected observer can run; HTTPS mutation requires
explicit per-host consent in mitm-allowed-hosts.txt; every mutation
logs to mitm-observed.log.
"""
import logging
import re
from datetime import datetime
from pathlib import Path
from mitmproxy import http
LOG_FILE = Path("mitm-observed.log")
ALLOWED_HOSTS_FILE = Path("mitm-allowed-hosts.txt")
BODY_CLOSE = re.compile(rb"</body>", re.IGNORECASE)
def _load_allowed_hosts() -> set[str]:
if not ALLOWED_HOSTS_FILE.exists():
return set()
return {
line.strip()
for line in ALLOWED_HOSTS_FILE.read_text().splitlines()
if line.strip() and not line.startswith("#")
}
def _log_mutation(host: str, path: str, scheme: str) -> None:
LOG_FILE.open("a").write(
f"{datetime.utcnow().isoformat()}Z\t{scheme}\t{host}\t{path}\n"
)
class ObserverInjector:
def __init__(self) -> None:
self.allowed_hosts = _load_allowed_hosts()
logging.info(f"[observer_inject] {len(self.allowed_hosts)} allowed hosts")
def responseheaders(self, flow: http.HTTPFlow) -> None:
if "text/html" not in flow.response.headers.get("content-type", ""):
return
if flow.request.scheme == "https" and flow.request.host not in self.allowed_hosts:
return
flow.response.headers.pop("content-security-policy", None)
flow.response.headers.pop("content-security-policy-report-only", None)
def response(self, flow: http.HTTPFlow) -> None:
if "text/html" not in flow.response.headers.get("content-type", ""):
return
if flow.request.scheme == "https" and flow.request.host not in self.allowed_hosts:
return
body = flow.response.content or b""
if not BODY_CLOSE.search(body):
return
inject = (
b'<script src="http://mitm.local/observer.js"></script>'
b'<script>window.__observer_config="/config/'
+ flow.request.host.encode()
+ b'.json";</script>'
)
flow.response.content = BODY_CLOSE.sub(inject + b"</body>", body, count=1)
_log_mutation(flow.request.host, flow.request.path, flow.request.scheme)
addons = [ObserverInjector()]
22.9. 21.9 Playwright driver
// Reads a per-session event JSON (output of scripts/parse-intent.clj
// --format json), replays the driven actions against the same URL,
// verifies the observer re-emits the same event set (R66).
import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';
const EVENT_FILE = process.env.EVENTS ?? 'session.json';
const TARGET_URL = process.env.URL ?? 'https://example.com/';
const CONFIG_URL = process.env.CONFIG_URL ?? '';
const events = JSON.parse(readFileSync(EVENT_FILE, 'utf8'));
const [sessionId, sessionEvents] = Object.entries(events)[0];
test(`replay ${sessionId} → ${TARGET_URL}`, async ({ page }) => {
await page.addInitScript({ path: './observer.js' });
await page.addInitScript({
content: `window.__observer_config="${CONFIG_URL}";`,
});
await page.goto(TARGET_URL);
const sorted = [...sessionEvents].sort((a, b) => a.ts - b.ts);
const t0 = sorted[0]?.ts ?? 0;
for (const evt of sorted) {
const delay = evt.ts - t0;
if (delay > 0) await page.waitForTimeout(Math.min(delay, 5000));
switch (evt.event) {
case 'element.click':
case 'sponsored.click':
await page.click(`[data-track-id="${evt.elId}"]`).catch(() => {});
break;
case 'element.hover':
await page.hover(`[data-track-id="${evt.elId}"]`).catch(() => {});
break;
case 'element.change':
if (evt.checked !== undefined) {
await page
.locator(`[data-track-id="${evt.elId}"]`)
.setChecked(evt.checked)
.catch(() => {});
}
break;
case 'scroll.depth':
await page.evaluate((pct) => {
const h = document.documentElement.scrollHeight - window.innerHeight;
window.scrollTo(0, (h * pct) / 100);
}, evt.depthPct);
break;
}
}
const replayed = await page.evaluate(
() => window.__wal_tracker?.dumpLog?.() ?? []
);
expect(replayed.length).toBeGreaterThan(0);
// R66 — replay produces AT LEAST the driven event names
const drivenNames = new Set(sorted.map((e) => e.event));
const replayedNames = new Set(replayed.map((e) => e.event));
for (const name of drivenNames) {
expect(replayedNames).toContain(name);
}
});
22.10. 21.10 Claude-in-Chrome recipe
# Claude-in-Chrome driver recipe (§17)
Claude-in-Chrome exposes CDP tools via `mcp__claude-in-chrome__*`.
This recipe turns Claude into the driver — records a natural-language
scenario, replays against a target, compares against the observer's
recorded event stream.
## Session recipe
1. `tabs_create_mcp` → open target URL.
2. `javascript_tool` → inject observer + config:
```javascript
const s = document.createElement('script');
s.src = 'https://obs.example.com/observer.js';
document.head.append(s);
await new Promise(r => s.onload = r);
window.__wal_tracker.init(YOUR_CONFIG_STRING);
```
3. `read_page` → let Claude enumerate trackable elements. Claude
proposes which selectors belong in the config `:elements` list.
4. Drive interactions per scenario. Between actions:
`read_console_messages` with `pattern: "\\[obs\\]"` to verify
each event fires exactly once (never zero, never twice).
5. `javascript_tool` → dump the buffer:
`JSON.stringify(window.__wal_tracker.dumpLog())`
6. Diff against reference. Prose differences fine; tag-bag
differences are spec bugs (§19).
## Anti-patterns
- No `alert()` / `confirm()` / `prompt()` — blocks CIC commands.
- No looping failing actions. Two tries, then stop.
- No enumerating `document.cookie` or `localStorage` beyond
observer's own key (§18 boundary).
## Auditor loop
Same buffer feeds the §17 LLM auditor. Operator reviews the auditor
output before ledger append (R70).
22.11. 21.11 parse-intent.clj — reverse the pixel stream
;; parse-intent.clj — reads Apache/Nginx access log, filters to
;; /observer/pixel hits (configurable), URL-decodes each query,
;; groups by session (or uid / ip), emits timeline / counts / edn / json.
(require '[clojure.string :as str]
'[clojure.java.io :as io]
'[clojure.pprint :as pp]
'[cheshire.core :as json])
(def log-re
#"^(\S+) \S+ \S+ \[([^\]]+)\] \"(\S+) (\S+) [^\"]*\" (\d{3}) (\d+|-) \"([^\"]*)\" \"([^\"]*)\".*")
(defn parse-line [line]
(when-let [m (re-matches log-re line)]
{:ip (nth m 1) :ts-log (nth m 2) :method (nth m 3)
:path (nth m 4) :status (parse-long (nth m 5))
:ref (nth m 7) :ua (nth m 8)}))
(defn decode [s]
(try (java.net.URLDecoder/decode s "UTF-8") (catch Exception _ s)))
(defn parse-query [qs]
(into {} (for [pair (str/split (or qs "") #"&")
:let [[k v] (str/split pair #"=" 2)]
:when (and k v)]
[(keyword k) (decode v)])))
(defn ->set [x] (if (coll? x) (set x) #{x}))
(defn match? [{:keys [uid session page event ip since]}]
(let [uids (when (seq uid) (->set uid))
events (when (seq event) (->set event))
since-ms (some-> since (str "T00:00:00Z")
(java.time.Instant/parse) .toEpochMilli)]
(fn [e]
(and (or (nil? uids) (contains? uids (:uid e)))
(or (nil? session) (= session (:session e)))
(or (nil? page) (str/includes? (or (:page e) "") page))
(or (nil? events) (contains? events (:event e)))
(or (nil? ip) (= ip (:ip e)))
(or (nil? since-ms) (and (:ts e) (>= (:ts e) since-ms)))))))
(defn iso-ts [ms]
(when ms (str (java.time.Instant/ofEpochMilli ms))))
(defn fmt-event [e]
(let [ts (:ts e)
stamp (or (some-> (iso-ts ts) (subs 11 23)) "??")
ev (or (:event e) "?")
rest-ks (dissoc e :event :ts :log-ts :ip :ua :session :uid
:page :ref :referrer :userAgent :screen :timezone :tz)]
(str stamp " " ev " "
(str/join " "
(for [[k v] (sort-by key rest-ks) :when (some? v)]
(str (name k) "=" v))))))
(defn print-timeline [groups {:keys [limit]}]
(doseq [[k evs] (sort-by first groups)]
(let [sorted (sort-by :ts evs)
shown (if limit (take limit sorted) sorted)
fe (first sorted)
span (when (and (:ts fe) (:ts (last sorted)))
(- (:ts (last sorted)) (:ts fe)))]
(println)
(println (str "── " k " (" (count evs) " events"
(when span (format ", %.1fs" (/ span 1000.0)))
")"))
(println (str " uid=" (:uid fe) " ip=" (:ip fe) " page=" (:page fe)))
(doseq [e shown] (println " " (fmt-event e))))))
(defn print-counts [groups _]
(doseq [[k evs] (sort-by first groups)]
(println (str "── " k " (" (count evs) " events)"))
(doseq [[e n] (->> evs (map :event) frequencies (sort-by val >))]
(println (format " %5d %s" n e)))))
(defn parse-args [argv]
(loop [args (seq argv)
opts {:format "timeline" :group "session"
:uid [] :event [] :pixel "/observer/pixel"}
paths []]
(if-not args
[opts (or (seq paths) nil)]
(let [a (first args) rst (next args)]
(case a
"--help" [(assoc opts :help true) nil]
"--uid" (recur (next rst) (update opts :uid conj (first rst)) paths)
"--event" (recur (next rst) (update opts :event conj (first rst)) paths)
"--session" (recur (next rst) (assoc opts :session (first rst)) paths)
"--page" (recur (next rst) (assoc opts :page (first rst)) paths)
"--ip" (recur (next rst) (assoc opts :ip (first rst)) paths)
"--since" (recur (next rst) (assoc opts :since (first rst)) paths)
"--limit" (recur (next rst) (assoc opts :limit (parse-long (first rst))) paths)
"--group" (recur (next rst) (assoc opts :group (first rst)) paths)
"--format" (recur (next rst) (assoc opts :format (first rst)) paths)
"--pixel" (recur (next rst) (assoc opts :pixel (first rst)) paths)
(recur rst opts (conj paths a)))))))
(defn -main [& argv]
(let [[opts paths] (parse-args argv)
prefix (str (:pixel opts) "?")
tracking? #(and (:path %) (str/starts-with? (:path %) prefix))
entry->event
(fn [entry]
(-> (parse-query (subs (:path entry) (count prefix)))
(assoc :ip (:ip entry) :ua (:ua entry) :log-ts (:ts-log entry))
(update :ts #(some-> % parse-long))))
source (mapcat (fn [p]
(when (or (= p "-") (.exists (io/file p)))
(line-seq (io/reader
(if (= p "-") *in* p)))))
(or paths ["logs/access.log"]))
events (->> source (keep parse-line) (filter tracking?)
(map entry->event) (filter (match? opts)))
group-key (case (:group opts) "uid" :uid "ip" :ip :session)
groups (group-by group-key events)]
(when (empty? groups)
(binding [*out* *err*] (println "no matching events"))
(System/exit 1))
(case (:format opts)
"edn" (pp/pprint groups)
"json" (println (json/generate-string groups {:pretty true}))
"counts" (print-counts groups opts)
(print-timeline groups opts))))
(apply -main *command-line-args*)
22.12. 21.12 LLM auditor prompt
# LLM auditor prompt (§17)
Fill placeholders and send to your model of choice (Claude Sonnet /
Opus recommended). Model name + this file's SHA-256 MUST accompany
any ledger append (R68).
## System
You are auditing a browsing session recorded by a passive-capture
observer. The event stream is deterministic per session; sampling is
100%. You never see the uid — one session at a time.
Your job:
1. Reconstruct what the user tried to do, in ONE paragraph.
2. Flag any event or sequence that looks anomalous.
3. Name the intent signals present (dwell, hover, click, form
abandon, checkout abandon, chumbox scroll-past, iframe
impressive, etc.).
Emit anomaly tags from THIS closed vocabulary (§17 R69). One tag
per line, prefix `TAG: `. Unknown → `TAG: unknown`.
- form-abandon
- rage-click
- back-and-forth
- sponsored-blind
- bot-shaped
- broken-affordance
- consent-abandon
- checkout-abandon
- add-to-cart-then-leave
- chumbox-scroll-past
- iframe-impressive
- unknown
Do NOT invent facts. If evidence doesn't support a claim, say so.
Do NOT propose fixes — you're an auditor, not a designer.
## User
Session: {{SESSION_ID}}
URL: {{FIRST_PAGEVIEW_PAGE}}
Duration: {{SPAN_SECONDS}}s
Event count: {{EVENT_COUNT}}
Events (chronological):
```
{{EVENT_STREAM}}
```
Return: (a) reconstruction paragraph; (b) anomaly tag list; (c)
intent-signal list.
23. 22. Cross-references
SPEC-tracker-base.org— tracker-rebuild base (R1-R22 of the wal.sh tracker; requirement numbers here are independent of that base)README.md— repo overview.verify/annotations.org— property-drawer verification index.verify/chain.jsonl— sibling ledger- Oracle:
jwalsh/www.wal.sh:src/wal_sh/site/tracker/— the CLJS implementation this spec is portable-ised from - Oracle:
jwalsh/www.wal.sh:scripts/tracker-intent.clj— the reference for §21.11's parse-intent.clj