feat(outbound): count the clicks a server log cannot see - #13
Merged
Merged
Conversation
…, 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 counttel: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
etela[href^="tel:"]nlabelmailtoa[href^="mailto:"]nlabelouthttp(s)link to another hosthhostname,nlabelamazon/merchanta[data-amazon]/a[data-merchant]nproduct,iposition,mmerchanttrack[data-track]that is not inside a linknlabelsrc/outbound.ts— pure helpers.describeClick(element, context)→ an event ornull;beaconUrl(click)renders it. adellion's query keys are unchanged, sooutbound_clicks.pykeeps reading its existing rows.src/outbound-clicks.tsx—<OutboundClicks beaconPath ignoreHosts enabled />. One delegatedclick+auxclicklistener on the document,credentials: 'omit',keepalive, rendersnull— pages stay server components.enabledlets a site with a consent gate hold it shut.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_KEYSis the whole vocabulary andsrc/outbound.test.tsfails if a seventh key appears without being named there — so widening it is a deliberate act, not a drift.Verification
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-domandweb-vitalsas devDependencies clear the three errorstsc --noEmitalready reported onmain.filesnow excludes*.test.tsso the vitest import never ships into a client site.What this does and does not do
1.3.0reaches sites only when the release tag is cut — that is Joe's word, separately.public/out.txt. Pilot is alloneexteriors.Open question for Joe
npm testruns locally only — there is no CI gate for it, becausepackage-lock.jsonis deliberately gitignored in this repo and a CInpm installwould 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 atest.ymlgate.🤖 Generated with Claude Code