Add the `contact_link_click` event tracking
- Dominant language
- JavaScript
- Stars
- 1.4k
- Forks
- 383
- Avg merge
- 4d 14h
- Merged PRs (30d)
- 77
Description
## Feature Description
GA4 Enhanced Measurement attributes `tel:` / `mailto:` / `sms:` clicks inconsistently, and it does not treat messaging-app links as contact channels at all: `https://wa.me/…` is just an external host, so Enhanced Measurement logs it as a generic outbound click like any other. A "message us on WhatsApp" button is common on WordPress business sites, and that click carries the same contact-the-business intent as a `tel:` tap—this new event classifies it.
This issue adds `contact_link_click` to the `content-events` frontend script, and with it the shared delegated click listener that `outbound_link_click` (#13291) extends. The listener resolves the clicked anchor once, classifies it once, and emits at most one event.
Three decisions shape the work:
**Links are classified by parsed protocol and host, not by a CSS selector.** CSS cannot express "hostname equals `wa.me`", and an `a[href^="tel:"]` prefix match misses `TEL:` and cannot tell a WhatsApp chat link from a WhatsApp share link. The `URL` parser lowercases and punycodes both values, so a match is an equality check against a fixed list of schemes and hosts, never a substring test — a look-alike host such as `notwa.me` is a different site, not WhatsApp.
**A messaging link counts only when it names a recipient.** Each messaging host serves three purposes: message someone, share this page, join a group. Only the first one means the visitor is contacting the business. Matching on the host alone would be simpler and would miss nothing, but a WhatsApp *share* button in a post template would then report a contact click every time someone shares a post, and the sites that publish share buttons are the same sites that use messaging most. Share links and group invites are therefore excluded. Two consequences are accepted: `t.me/` looks the same for a direct chat and for a public channel, so following a channel is counted as contact; and every app needs its own rule for what a recipient looks like.
**No identifier is ever sent.** The recipient sits in the URL itself (`wa.me/15551234567`, `signal.me/#p/+1555…`, `t.me/`), so this event reports `link_type` and nothing else. `outbound_link_click` (#13291) does report the full URL, in its `link_url` param — so once a link classifies as a contact link the shared listener emits `contact_link_click` and stops, and never lets that anchor reach the outbound handler. Without that, a `rel="nofollow"` WhatsApp link — a common combination — would send the recipient's phone number to GA in `link_url`, and one click would be counted as two events.
Link to the design doc: https://docs.google.com/document/d/1F23KG9do9PMzZpe-KhCfGw97C5BFIrEt5otC4j4ITn0/edit?tab=t.saezzs38gdl
---------------
_Do not alter or remove anything below. The following sections will be managed by moderators only._
## Acceptance criteria
* Clicking a link of one of the kinds below sends one `contact_link_click` event to GA, with `link_type` set to the value in the second column:
| Link the visitor clicks | `link_type` |
|---|---|
| `tel:+15551234567` | `phone` |
| `mailto:hello@example.com` | `email` |
| `sms:+15551234567`, `smsto:+15551234567` | `sms` |
| `https://wa.me/15551234567`, `https://wa.me/message/ABC123`, `https://wa.me/qr/ABC123`, `https://api.whatsapp.com/send?phone=15551234567`, `https://web.whatsapp.com/send?phone=15551234567`, `whatsapp://send?phone=15551234567` | `whatsapp` |
| `https://m.me/acme`, `https://messenger.com/t/12345`, `fb-messenger://user-thread/12345`, `fb-messenger://user/12345` | `messenger` |
| `https://t.me/acme`, `https://telegram.me/acme`, `tg://resolve?domain=acme` | `telegram` |
| `viber://chat?number=%2B15551234567`, `viber://add?number=%2B15551234567`, `viber://pa?chatURI=acme` | `viber` |
| `https://signal.me/#p/+15551234567`, `sgnl://signal.me/#p/+15551234567` | `signal` |
| `https://line.me/R/ti/p/@acme`, `https://line.me/ti/p/@acme`, `https://page.line.me/acme`, `line://ti/p/@acme` | `line` |
* The event carries `link_type` and nothing else. It never carries the phone number, the email address, the messaging username or page name, the prefilled message text, or the link URL.
* Clicking any of these sends **no** event at all:
* a link that shares the page instead of contacting someone — `https://wa.me/?text=Hello`, `https://t.me/share/url?url=…`, `viber://forward?text=…`, `https://line.me/R/msg/text/?Hello`, any link to `social-plugins.line.me`;
* a link that joins a group — `https://chat.whatsapp.com/`, `https://t.me/+`, `https://t.me/joinchat/`, any link to `signal.group`;
* a messaging link that names no one — `https://wa.me/`, `https://m.me/`, `https://t.me/`, `https://page.line.me/`;
* a Telegram link that opens something other than a conversation — `https://t.me/addstickers/example`, `https://t.me/proxy?server=example.com`;
* a link to a different site whose address only looks similar — `https://notwa.me/1555`, `https://wa.me.evil.com/1555`, `https://sub.t.me/acme`, `https://evil.com/?r=wa.me`;
* any other link on the page.
* One click sends exactly one event. A contact link that also carries `rel="nofollow"`, `rel="sponsored"` or `rel="ugc"` sends `contact_link_click` only, and no `outbound_link_click` (#13291).
* The link keeps working normally: the phone dialer, mail client, messaging app or page opens as it did before, and the event is still recorded when the click leaves the page.
* Upper-case and `www.` variants behave the same as the forms in the table — `TEL:+15551234567` sends `link_type: 'phone'`, and `https://WWW.T.me/Acme` sends `link_type: 'telegram'`.
* Clicking an icon, image or text inside the link counts as clicking the link, and a link added to the page after it loaded (a floating chat button, for example) is tracked in the same way.
## Implementation Brief
* [ ] In `assets/js/event-providers/content-events/classify-contact-link.ts` (new file):
* Export `type ContactLinkType` as the union of the nine values in the acceptance criteria table, and `interface ContactLinkMatcher { type: ContactLinkType; schemes?: string[]; hosts?: string[]; hasRecipient?: ( url: URL ) => boolean; }`.
* Export `CONTACT_LINK_MATCHERS: ContactLinkMatcher[]` — one row per `link_type`, in the order of that table. `hosts` and `hasRecipient` are omitted where they do not apply:
* `phone` — schemes `[ 'tel:' ]`.
* `email` — schemes `[ 'mailto:' ]`.
* `sms` — schemes `[ 'sms:', 'smsto:' ]`.
* `whatsapp` — schemes `[ 'whatsapp:' ]`, hosts `[ 'wa.me', 'api.whatsapp.com', 'web.whatsapp.com' ]`.
* `messenger` — schemes `[ 'fb-messenger:' ]`, hosts `[ 'm.me', 'messenger.com' ]`.
* `telegram` — schemes `[ 'tg:' ]`, hosts `[ 't.me', 'telegram.me' ]`.
* `viber` — schemes `[ 'viber:' ]`.
* `signal` — schemes `[ 'sgnl:' ]`, hosts `[ 'signal.me' ]`.
* `line` — schemes `[ 'line:' ]`, hosts `[ 'line.me', 'page.line.me' ]`.
* `chat.whatsapp.com`, `signal.group` and `social-plugins.line.me` appear in no `hosts` list, so a link to one of them matches no row and is not classified.
* Build two module-level indexes from that one table in a single pass — `Record< string, ContactLinkMatcher >` for scheme → row and for host → row — so a click costs two hash lookups.
* `hasRecipient( url: URL )` per row, branching on `url.protocol` and `url.hostname`; take path segments as `url.pathname.split( '/' ).filter( Boolean )`:
* `whatsapp` — `wa.me`: first segment is all digits, or the first two segments are `message`/`qr` plus a non-empty code. `api.whatsapp.com` / `web.whatsapp.com` and scheme `whatsapp:`: `url.searchParams.get( 'phone' )` is non-empty.
* `messenger` — `m.me`: at least one path segment. `messenger.com`: first two segments are `t` plus a non-empty id. Scheme `fb-messenger:`: `url.hostname` is `user-thread` or `user`.
* `telegram` — `t.me` / `telegram.me`: a first path segment exists, does not start with `+`, and is none of `share`, `joinchat`, `addstickers`, `proxy`. Scheme `tg:`: `url.hostname` is `resolve` and `url.searchParams.get( 'domain' )` is non-empty.
* `viber` — `url.hostname` is `chat`, `add` or `pa`.
* `signal` — `signal.me`: `url.hash` starts with `#p/` and carries a value after it. Scheme `sgnl:`: always true.
* `line` — `line.me`: path segments are `R`/`ti`/`p`/`` or `ti`/`p`/`` with a non-empty id. `page.line.me`: at least one path segment. Scheme `line:`: `${ url.hostname }${ url.pathname }` starts with `ti/p/` and carries a non-empty id.
* Export a default `classifyContactLink( anchor: HTMLAnchorElement ): ContactLinkType | null`:
* `new URL( anchor.href )` inside a `try`/`catch`; return `null` when it throws.
* For `http:` / `https:`, look the row up in the host index by `url.hostname.replace( /^www\./, '' )`; for every other scheme, look it up in the scheme index by `url.protocol`. Both keys come from the parser already lowercased, so the comparison is plain equality — never `includes()` or a prefix test.
* Return `null` when no row matches, the row's `type` when it has no `hasRecipient`, and otherwise `hasRecipient( url ) ? type : null`.
* [ ] In `assets/js/event-providers/content-events/link-clicks.ts` (new file):
* Export `initializeLinkClicks(): void`, adding the **single** delegated `click` listener on `document` that both this event and `outbound_link_click` (#13291) use — registered unconditionally, so links injected after load are covered.
* In the listener: narrow `event.target` with `instanceof Element`, resolve `closest( 'a[href]' )` as `HTMLAnchorElement | null` and return when nothing resolves; call `classifyContactLink( anchor )` once.
* When it returns a `link_type`, emit `global._googlesitekit?.gtagEvent?.( 'contact_link_click', { link_type } )` — plus `transport_type: 'beacon'` when the anchor's protocol is `http:` or `https:` — and **return**, so no other handler receives this anchor.
* Leave the `else` branch empty; #13291 adds the outbound handling there.
* Wrap the body of the listener in a `try`/`catch`, so an error thrown while handling one click cannot stop later clicks from being handled.
* [ ] In `assets/js/event-providers/content-events.ts` (added in #13281):
* Call `initializeLinkClicks()` from its own `try`/`catch`, alongside the existing handler calls.
### Test Coverage
* Add `assets/js/event-providers/content-events/classify-contact-link.test.ts`, table-driven over one `describe` per `link_type`, covering for each row at least one matching href per listed form and the near-misses:
* `tel:`, `mailto:`, `sms:`, `smsto:` classify, and `TEL:`/`MAILTO:` classify identically.
* WhatsApp: `wa.me/15551234567`, `wa.me/message/ABC123`, `wa.me/qr/ABC123`, `api.whatsapp.com/send?phone=…`, `web.whatsapp.com/send?phone=…`, `whatsapp://send?phone=…` classify; `wa.me/?text=…`, `wa.me/notanumber`, `api.whatsapp.com/send?text=…` and `chat.whatsapp.com/` do not.
* Messenger: `m.me/acme`, `messenger.com/t/12345`, `www.messenger.com/t/12345`, `fb-messenger://user-thread/12345`, `fb-messenger://user/12345` classify; `m.me/` and `messenger.com/` do not.
* Telegram: `t.me/acme`, `telegram.me/acme`, `tg://resolve?domain=acme` classify; `t.me/share/url?url=…`, `t.me/joinchat/`, `t.me/+`, `t.me/addstickers/x`, `t.me/proxy?…`, `t.me/` and `tg://resolve` without a `domain` do not.
* Viber: `viber://chat?number=…`, `viber://add?number=…`, `viber://pa?chatURI=…` classify; `viber://forward?text=…` does not.
* Signal: `signal.me/#p/+15551234567` and `sgnl://…` classify; `signal.me/` with no fragment and `signal.group/#…` do not.
* LINE: `line.me/R/ti/p/@acme`, `line.me/ti/p/@acme`, `page.line.me/acme`, `line://ti/p/@acme` classify; `line.me/R/msg/text/?hello`, `social-plugins.line.me/lineit/share?url=…` and `page.line.me/` do not.
* Host matching is exact: `https://evil.com/?r=wa.me`, `https://notwa.me/1555`, `https://wa.me.evil.com/1555` and `https://sub.t.me/acme` classify as nothing, while a `www.`-prefixed form of a listed host classifies.
* A malformed `href` returns `null` rather than throwing.
* Every `type` in `CONTACT_LINK_MATCHERS` is one of the nine documented values, and no host appears in two rows.
* Add `assets/js/event-providers/content-events/link-clicks.test.ts` covering:
* A click on a classified anchor emits `contact_link_click` once, with `link_type` as the only payload key — asserting the absence of any href, host, path, number, address, username or fragment.
* A click landing on an `` child of the anchor resolves up to the anchor and emits the same event.
* An anchor appended to the DOM after `initializeLinkClicks()` ran is classified.
* `transport_type: 'beacon'` is present for an `https:` messaging link and absent for `tel:`, `mailto:`, `sms:` and an app-scheme link.
* A click on an unclassified anchor, on a non-anchor element and on an `a` without `href` emits nothing.
* A classified anchor carrying `rel="nofollow"` still emits exactly one event.
* One call to `initializeLinkClicks()` registers exactly one `document` click listener.
* A listener invocation that throws does not prevent the next click from emitting.
* Extend `assets/js/event-providers/content-events.test.ts` (added in #13281) with the link-clicks initializer being invoked and a throw from it not propagating out of the entry module.
## QA Brief
**Setup:** publish a post containing one link per row of the acceptance criteria table, plus the excluded forms (share, group invite, recipient-less, look-alike hosts). Browse the frontend **logged out** — Site Kit excludes logged-in users from tracking, so the events script is not enqueued for an admin. Disable any ad blocker; it blocks `googlesitekit-events-provider-*` outright and nothing will fire.
Use the GA4 debugger or similar browser extension to track the events in console log. Note at the bottom of this is section is copy-paste ready HTML block if you want to transfer all links from AC into the blog.
* Click one link per row and confirm each payload matches the acceptance criteria table.
* Confirm every excluded form in the criteria emits nothing — in particular the share links, the group invites, and the look-alike hosts.
* Confirm no payload ever contains the phone number, email address, username, page name, prefilled message text or the URL. Check the same in the network tab: the `g/collect` request should carry `ep.link_type` and no recipient anywhere in the query string.
* Click a contact link carrying `rel="nofollow"` and confirm exactly one event.
* Click an icon or text nested inside a link, and a link injected after load (paste a floating chat button into the console), and confirm both behave like a direct click.
* Confirm the beacon transport is present for the `https:` links and absent for `tel:`, `mailto:`, `sms:` and the app-scheme links.
* Finally, click a link normally with no console override and confirm the dialer, mail client, app or page opens exactly as before.
---
Copy-paste ready full list of links:
```html
Should send contact_link_click
messenger — user-thread scheme
whatsapp — with rel=nofollow (must send ONE event)
email — click this nested span
Must send nothing
share — t.me Share (mixed case)
group — t.me JoinChat (mixed case)
not a conversation — addstickers
```
Publish this into HTML block in a **Custom HTML** block, as an Administrator on a single site, then view the post source and confirm every `href` survived before testing.
## Changelog entry
* Add link tracking to contact links.
Contributor guide
Research direction
Start with assets/js/event-providers/content-events/classify-contact-link.ts and its table-driven test, then read assets/js/event-providers/content-events/link-clicks.ts and content-events.ts to trace initialization and delegated clicks. Run the new classifier tests and verify the listed contact links emit one event with only link_type while excluded links emit none and existing navigation remains intact.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, typescript
- Domain
- analytics, frontend, testing
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100