Skip to content

feat(outbound): count the clicks a server log cannot see - #13

Merged
DelanoJoey merged 1 commit into
mainfrom
feat/outbound-click-beacon
Sep 19, 2026
Merged

DelanoJoey merged 1 commit into
mainfrom
feat/outbound-click-beacon

Conversation

@DelanoJoey

Copy link
Copy Markdown
Collaborator

Why

Four client sites in the fleet have no working analytics at all, and the one action that matters on them — a visitor tapping the phone number — leaves no trace anywhere. A CloudFront access log sees page requests; it cannot see a tel: tap, an email link, a link out to a merchant, or a booking widget opening. GA4 does not count tel: taps by default even where a tag is present, and the shared <Analytics> was found not to run client-side on three sites on 9/18.

adellion #57 (961c941) proved the pattern on product links. This generalizes it into the shared package so the sites that need it adopt ~90 lines instead of each inventing their own, and so the privacy properties are written down once, in one place, pinned by tests.

What

e fires on also carries
tel a[href^="tel:"] n label
mailto a[href^="mailto:"] n label
out an http(s) link to another host h hostname, n label
amazon / merchant a[data-amazon] / a[data-merchant] n product, i position, m merchant
track any [data-track] that is not inside a link n label
  • src/outbound.ts — pure helpers. describeClick(element, context) → an event or null; beaconUrl(click) renders it. adellion's query keys are unchanged, so outbound_clicks.py keeps reading its existing rows.
  • src/outbound-clicks.tsx — <OutboundClicks beaconPath ignoreHosts enabled />. One delegated click + auxclick listener on the document, credentials: 'omit', keepalive, renders null — pages stay server components. enabled lets a site with a consent gate hold it shut.
  • Internal links are deliberately silent: a click that ends in a request to our own server is already in the same log as a page view.

The privacy boundary

What may leave the page is an event name, the current path, a hostname, and labels written in the site's own source. Never a phone number, an email address, a link's path or query string, or anything a visitor typed. BEACON_KEYS is the whole vocabulary and src/outbound.test.ts fails if a seventh key appears without being named there — so widening it is a deliberate act, not a drift.

Verification

vitest        32/32 passed
tsc --noEmit  exit 0
npm pack      ships src/outbound.ts + src/outbound-clicks.tsx, no test file

The package had no test runner. Added vitest + jsdom; the tests click real elements and assert one beacon per tap, no cookie header, no preventDefault, the href untouched, nothing for an internal link, nothing while disabled, nothing after unmount, and that a phone number and an email address are absent from the beacon URL. (jsdom prints "Not implemented: navigation to another Document" during the run — that is the href being honoured.)

@types/node, @types/react-dom and web-vitals as devDependencies clear the three errors tsc --noEmit already reported on main. files now excludes *.test.ts so the vitest import never ships into a client site.

What this does and does not do

  • Nothing deploys. No consuming site is touched and no tag is pushed. 1.3.0 reaches sites only when the release tag is cut — that is Joe's word, separately.
  • Adoption is per-site, one PR at a time, each with its own privacy-policy line and its own public/out.txt. Pilot is alloneexteriors.
  • Rollback: revert this commit; nothing consumes it yet.

Open question for Joe

npm test runs locally only — there is no CI gate for it, because package-lock.json is deliberately gitignored in this repo and a CI npm install would resolve floating dependency versions on every run, against the fleet's no-floating-refs standard. Committing a lockfile is a repo policy change, so it is left out of this PR. Say the word and it becomes a test.yml gate.

🤖 Generated with Claude Code

…, emails, links off the site

Four client sites in the fleet run with no working analytics at all, and the
one action that matters on them — a visitor tapping the phone number — leaves
no trace anywhere. The access log sees page requests; it cannot see a `tel:`
tap, an email link, a link out to a merchant, or a booking widget opening.
GA4 does not count `tel:` taps by default even where a tag is present.

adellion #57 proved the pattern on product links: one delegated listener, a
GET for a 3-byte static file, and CloudFront's access log is the counter. This
generalizes it so the sites that need it can adopt ~90 lines instead of each
inventing their own, and so the privacy properties are written down once.

- src/outbound.ts — pure helpers. `describeClick` turns a clicked element into
  an event or null; `beaconUrl` renders it. Six events: tel, mailto, out,
  amazon, merchant, track. adellion's query keys are unchanged so
  outbound_clicks.py keeps reading its rows.
- src/outbound-clicks.tsx — `<OutboundClicks beaconPath ignoreHosts enabled />`.
  One delegated click + auxclick listener on the document, credentials omitted,
  keepalive, renders null, so pages stay server components. `enabled` lets a
  site with a consent gate hold it shut.
- Internal links are deliberately silent: a click that ends in a request to our
  own server is already in the same log as a page view.
- What may leave the page: an event name, the path, a hostname, and labels the
  site's own source wrote. Never a phone number, an email address, a link's path
  or query, or anything a visitor typed. BEACON_KEYS is the whole vocabulary and
  the test fails if a seventh key appears without being named there.
- The package had no test runner. Added vitest + jsdom and 32 tests that click
  real elements: one beacon per tap, no cookie header, no preventDefault, the
  href untouched, nothing for an internal link, nothing while disabled, nothing
  after unmount, and phone numbers and emails absent from the URL.
- @types/node, @types/react-dom and web-vitals as devDependencies clear the
  three errors `tsc --noEmit` already reported; it now exits 0.
- `files` excludes *.test.ts so the vitest import never ships to a client site.

No consuming site is touched by this commit and no tag is pushed, so nothing
deploys. 1.3.0 reaches sites only when the release tag is cut.

Verified: vitest 32/32, tsc --noEmit exit 0, npm pack ships src/outbound.ts +
src/outbound-clicks.tsx and no test file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DelanoJoey
DelanoJoey merged commit 9344b4d into main Sep 19, 2026
1 check passed
@DelanoJoey
DelanoJoey deleted the feat/outbound-click-beacon branch September 19, 2026 22:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant