google / google/site-kit-wp

Add the `contact_link_click` event tracking

Closed
#13,290 5 comments 0 reactions 0 assignees View on GitHub
P1 Team S Type: Enhancement
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


phone — tel


phone — uppercase TEL


email — mailto


sms — sms


sms — smsto


whatsapp — wa.me number


whatsapp — wa.me message


whatsapp — wa.me qr


whatsapp — api send


whatsapp — web send


whatsapp — app scheme


messenger — m.me


messenger — messenger.com/t


messenger — user-thread scheme


messenger — user scheme


telegram — t.me


telegram — telegram.me


telegram — tg scheme


telegram — uppercase + www


viber — chat


viber — add


viber — pa


signal — signal.me


signal — sgnl scheme


line — R/ti/p


line — ti/p


line — page.line.me


line — line scheme


whatsapp — with rel=nofollow (must send ONE event)


email — click this nested span

Must send nothing


share — wa.me text


share — t.me share


share — t.me Share (mixed case)


share — viber forward


share — line msg


share — line social plugin


group — whatsapp invite


group — t.me plus hash


group — t.me joinchat


group — t.me JoinChat (mixed case)


group — signal.group


no recipient — wa.me


no recipient — m.me


no recipient — t.me


no recipient — page.line.me


not a conversation — addstickers


not a conversation — proxy


look-alike — notwa.me


look-alike — wa.me.evil.com


look-alike — sub.t.me


look-alike — wa.me in query


any other link


```
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.