From 0ee4dd577b5943c71e40ba24814fb209d3e85730 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 14:20:01 +0000 Subject: [PATCH 1/6] docs: send primary conversion events to the first-party conversion worker Captures the Google Ads click id (gclid/wbraid/gbraid) on landing and stores it for 90 days. The six primary conversion events (dev_mode_tracking_iframe, console-log-click, premium_lead, request-demo-sub, copy_on_page, visit_x_urls) are additionally sent via sendBeacon to the rxdb-events Cloudflare worker, which Google Ads pulls as offline conversions so ad blockers cannot prevent conversion tracking. When gtag is blocked, a client id is attached so the worker forwards the event to the GA4 Measurement Protocol; users with working gtag are excluded from that path to prevent double counting. Also corrects the revisit_3_days comment (self-triggering conversions must not be primary). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EPJvU6oisC97X9uzc1qfGC --- docs-src/src/components/trigger-event.tsx | 117 ++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/docs-src/src/components/trigger-event.tsx b/docs-src/src/components/trigger-event.tsx index 9817256a232..cf469219da1 100644 --- a/docs-src/src/components/trigger-event.tsx +++ b/docs-src/src/components/trigger-event.tsx @@ -11,6 +11,117 @@ export type RedditEventType = | 'Lead' | 'Purchase'; +/** + * Google Ads primary conversion events. + * These are additionally sent to our first-party conversion worker which + * Google Ads pulls as offline conversions (ad blockers cannot prevent that). + * Keep in sync with the worker's PRIMARY_EVENTS allowlist + * (rxdb-internals: google-ads/conversion-worker/src/worker.js). + */ +export const GOOGLE_ADS_PRIMARY_EVENTS = new Set([ + 'dev_mode_tracking_iframe', + 'console-log-click', + 'premium_lead', + 'request-demo-sub', + 'copy_on_page', + 'visit_x_urls' +]); + +const CONVERSION_WORKER_URL = 'https://rxdb-events.daniel-meyer-e90.workers.dev/api/e'; +/** + * Written by storeAdClickId() in Root.tsx when the user lands with a + * gclid/gbraid/wbraid URL param. Shape: { k, v, t }. + */ +const AD_CLICK_STORAGE_ID = 'click_id'; +// Google Ads click-through window; older click ids can no longer convert. +const AD_CLICK_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; + +function getStoredAdClickId(): { k: string; v: string; t: number; } | null { + try { + const raw = localStorage.getItem(AD_CLICK_STORAGE_ID); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw); + if (!parsed || !parsed.v || (Date.now() - parsed.t) > AD_CLICK_MAX_AGE_MS) { + return null; + } + return parsed; + } catch (err) { + return null; + } +} + +/** + * GA4 client id: the real one from the _ga cookie when google analytics + * runs, otherwise a self-minted stable id. Only used when gtag is blocked, + * so the GA4 Measurement Protocol can still count the event. + */ +function getOrMintClientId(): string { + const fromCookie = document.cookie.match(/_ga=GA\d+\.\d+\.(\d+\.\d+)/); + if (fromCookie) { + return fromCookie[1]; + } + let cid = localStorage.getItem('worker_cid'); + if (!cid) { + cid = Math.floor(Math.random() * 1e10) + '.' + Math.floor(Date.now() / 1000); + localStorage.setItem('worker_cid', cid); + } + return cid; +} + +function getSessionId(): string { + try { + let sid = sessionStorage.getItem('worker_sid'); + if (!sid) { + sid = Math.floor(Date.now() / 1000) + ''; + sessionStorage.setItem('worker_sid', sid); + } + return sid; + } catch (err) { + return Math.floor(Date.now() / 1000) + ''; + } +} + +/** + * Sends a primary event to the conversion worker: + * - with the stored ad click id (if any) so Google Ads can import it as an + * offline conversion, independent of ad blockers, + * - and, ONLY when gtag is blocked, with a client id so the worker forwards + * the event to the GA4 Measurement Protocol. Users whose gtag runs already + * report to GA4 client-side; sending both would double-count them. + */ +function sendToConversionWorker(type: string, value: number) { + try { + if (!GOOGLE_ADS_PRIMARY_EVENTS.has(type)) { + return; + } + const adClick = getStoredAdClickId(); + const gaBlocked = typeof (window as any).gtag !== 'function'; + if (!adClick && !gaBlocked) { + return; + } + const payload: any = { type, value }; + if (adClick) { + payload.clid = adClick.v; + payload.clidKind = adClick.k; + } + if (gaBlocked) { + payload.cid = getOrMintClientId(); + payload.sid = getSessionId(); + } + const body = JSON.stringify(payload); + if (navigator.sendBeacon) { + navigator.sendBeacon(CONVERSION_WORKER_URL, body); + } else { + fetch(CONVERSION_WORKER_URL, { method: 'POST', body, keepalive: true }).catch(() => { }); + } + } catch (err) { + console.log('# Error on conversion-worker trigger:'); + console.dir(err); + } +} + export function triggerTrackingEvent( type: string, value: number, @@ -40,6 +151,12 @@ export function triggerTrackingEvent( console.log('triggerTrackingEvent(' + type + ', ' + value + ', redditEventType=' + redditEventType + ' ' + triggeredBefore + '/' + maxPerUser + ')'); + /** + * Google Ads conversion worker (runs after the same frequency capping + * as the other trackers). + */ + sendToConversionWorker(type, value); + /** * Reddit does not have a concept of conversion-value * so we only track primary events because otherwise everything would From b39405700983dc7e82bc88aaa2ca29dd253f2ee5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 09:34:06 +0000 Subject: [PATCH 2/6] docs: drop client-side max-age check on the stored click id Google Ads rejects click ids outside the click-through window at import time anyway, so the client does not need its own TTL logic. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EPJvU6oisC97X9uzc1qfGC --- docs-src/src/components/trigger-event.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs-src/src/components/trigger-event.tsx b/docs-src/src/components/trigger-event.tsx index cf469219da1..599718e4559 100644 --- a/docs-src/src/components/trigger-event.tsx +++ b/docs-src/src/components/trigger-event.tsx @@ -33,8 +33,6 @@ const CONVERSION_WORKER_URL = 'https://rxdb-events.daniel-meyer-e90.workers.dev/ * gclid/gbraid/wbraid URL param. Shape: { k, v, t }. */ const AD_CLICK_STORAGE_ID = 'click_id'; -// Google Ads click-through window; older click ids can no longer convert. -const AD_CLICK_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; function getStoredAdClickId(): { k: string; v: string; t: number; } | null { try { @@ -43,7 +41,7 @@ function getStoredAdClickId(): { k: string; v: string; t: number; } | null { return null; } const parsed = JSON.parse(raw); - if (!parsed || !parsed.v || (Date.now() - parsed.t) > AD_CLICK_MAX_AGE_MS) { + if (!parsed || !parsed.v) { return null; } return parsed; From 3d055af07f3c11fec6f264d93f3f5532e1819a48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 09:35:33 +0000 Subject: [PATCH 3/6] docs: share the AD_CLICK_STORAGE_ID constant between Root.tsx and trigger-event.tsx Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EPJvU6oisC97X9uzc1qfGC --- docs-src/src/components/trigger-event.tsx | 2 +- docs-src/src/theme/Root.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-src/src/components/trigger-event.tsx b/docs-src/src/components/trigger-event.tsx index 599718e4559..35b7509852a 100644 --- a/docs-src/src/components/trigger-event.tsx +++ b/docs-src/src/components/trigger-event.tsx @@ -32,7 +32,7 @@ const CONVERSION_WORKER_URL = 'https://rxdb-events.daniel-meyer-e90.workers.dev/ * Written by storeAdClickId() in Root.tsx when the user lands with a * gclid/gbraid/wbraid URL param. Shape: { k, v, t }. */ -const AD_CLICK_STORAGE_ID = 'click_id'; +export const AD_CLICK_STORAGE_ID = 'click_id'; function getStoredAdClickId(): { k: string; v: string; t: number; } | null { try { diff --git a/docs-src/src/theme/Root.tsx b/docs-src/src/theme/Root.tsx index 233cd06b23a..54ad69df242 100644 --- a/docs-src/src/theme/Root.tsx +++ b/docs-src/src/theme/Root.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { onCopy, triggerTrackingEvent } from '../components/trigger-event'; +import { AD_CLICK_STORAGE_ID, onCopy, triggerTrackingEvent } from '../components/trigger-event'; import { randomNumber } from '../../../plugins/utils'; import { IconClose } from '../components/icons/close'; import { Button } from '../components/button'; @@ -317,7 +317,7 @@ function storeAdClickId() { for (const k of ['gclid', 'gbraid', 'wbraid']) { const v = p.get(k); if (v) { - localStorage.setItem('click_id', JSON.stringify({ k, v, t: Date.now() })); + localStorage.setItem(AD_CLICK_STORAGE_ID, JSON.stringify({ k, v, t: Date.now() })); triggerTrackingEvent('click_id_' + k, 0.01, 1); } } From 54866a98a39f20071dc9d46159d58ede52ff9ed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 09:39:47 +0000 Subject: [PATCH 4/6] docs: send all tracking events to the conversion worker No client-side event filter anymore: every event goes to the worker when a click id is stored (Google Ads decides via its conversion actions which names count) and every event when gtag is blocked (GA4 Measurement Protocol counting). Requires the matching worker change to be deployed first. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EPJvU6oisC97X9uzc1qfGC --- docs-src/src/components/trigger-event.tsx | 27 +++++------------------ 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/docs-src/src/components/trigger-event.tsx b/docs-src/src/components/trigger-event.tsx index 35b7509852a..eba740c8d31 100644 --- a/docs-src/src/components/trigger-event.tsx +++ b/docs-src/src/components/trigger-event.tsx @@ -11,22 +11,6 @@ export type RedditEventType = | 'Lead' | 'Purchase'; -/** - * Google Ads primary conversion events. - * These are additionally sent to our first-party conversion worker which - * Google Ads pulls as offline conversions (ad blockers cannot prevent that). - * Keep in sync with the worker's PRIMARY_EVENTS allowlist - * (rxdb-internals: google-ads/conversion-worker/src/worker.js). - */ -export const GOOGLE_ADS_PRIMARY_EVENTS = new Set([ - 'dev_mode_tracking_iframe', - 'console-log-click', - 'premium_lead', - 'request-demo-sub', - 'copy_on_page', - 'visit_x_urls' -]); - const CONVERSION_WORKER_URL = 'https://rxdb-events.daniel-meyer-e90.workers.dev/api/e'; /** * Written by storeAdClickId() in Root.tsx when the user lands with a @@ -82,18 +66,17 @@ function getSessionId(): string { } /** - * Sends a primary event to the conversion worker: - * - with the stored ad click id (if any) so Google Ads can import it as an - * offline conversion, independent of ad blockers, + * Sends every tracking event to the conversion worker: + * - when an ad click id is stored, so Google Ads can import the event as an + * offline conversion, independent of ad blockers (which event names count, + * and whether they are primary or secondary, is decided in Google Ads by + * which conversion actions exist), * - and, ONLY when gtag is blocked, with a client id so the worker forwards * the event to the GA4 Measurement Protocol. Users whose gtag runs already * report to GA4 client-side; sending both would double-count them. */ function sendToConversionWorker(type: string, value: number) { try { - if (!GOOGLE_ADS_PRIMARY_EVENTS.has(type)) { - return; - } const adClick = getStoredAdClickId(); const gaBlocked = typeof (window as any).gtag !== 'function'; if (!adClick && !gaBlocked) { From bbacdd4f7c06f37a5dc54923b0c7a139b8b595a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 09:44:08 +0000 Subject: [PATCH 5/6] docs: send events to the worker independent of gtag state Important conversion events are always sent to the worker; all other events are sent whenever a click id is stored. The client id is always attached so the worker can forward events to the GA4 Measurement Protocol regardless of whether google analytics is blocked. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EPJvU6oisC97X9uzc1qfGC --- docs-src/src/components/trigger-event.tsx | 50 +++++++++++++++-------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/docs-src/src/components/trigger-event.tsx b/docs-src/src/components/trigger-event.tsx index eba740c8d31..08cb2bbc580 100644 --- a/docs-src/src/components/trigger-event.tsx +++ b/docs-src/src/components/trigger-event.tsx @@ -18,6 +18,21 @@ const CONVERSION_WORKER_URL = 'https://rxdb-events.daniel-meyer-e90.workers.dev/ */ export const AD_CLICK_STORAGE_ID = 'click_id'; +/** + * Important conversion events that are ALWAYS sent to the conversion worker, + * even without an ad click id, so they are never lost to ad blockers. + * All other events are sent only when a click id is stored (then Google Ads + * attribution is possible). + */ +export const IMPORTANT_WORKER_EVENTS = new Set([ + 'dev_mode_tracking_iframe', + 'console-log-click', + 'premium_lead', + 'request-demo-sub', + 'copy_on_page', + 'visit_x_urls' +]); + function getStoredAdClickId(): { k: string; v: string; t: number; } | null { try { const raw = localStorage.getItem(AD_CLICK_STORAGE_ID); @@ -36,8 +51,8 @@ function getStoredAdClickId(): { k: string; v: string; t: number; } | null { /** * GA4 client id: the real one from the _ga cookie when google analytics - * runs, otherwise a self-minted stable id. Only used when gtag is blocked, - * so the GA4 Measurement Protocol can still count the event. + * runs, otherwise a self-minted stable id. The worker uses it to forward + * the event to the GA4 Measurement Protocol. */ function getOrMintClientId(): string { const fromCookie = document.cookie.match(/_ga=GA\d+\.\d+\.(\d+\.\d+)/); @@ -66,31 +81,32 @@ function getSessionId(): string { } /** - * Sends every tracking event to the conversion worker: - * - when an ad click id is stored, so Google Ads can import the event as an - * offline conversion, independent of ad blockers (which event names count, - * and whether they are primary or secondary, is decided in Google Ads by - * which conversion actions exist), - * - and, ONLY when gtag is blocked, with a client id so the worker forwards - * the event to the GA4 Measurement Protocol. Users whose gtag runs already - * report to GA4 client-side; sending both would double-count them. + * Sends tracking events to the conversion worker, independent of whether + * google analytics is blocked or not: + * - important events are ALWAYS sent, + * - all other events are sent when an ad click id is stored, so Google Ads + * can import them as offline conversions (which event names count, and + * whether they are primary or secondary, is decided in Google Ads by which + * conversion actions exist). + * The client id is always attached so the worker can forward the event to + * the GA4 Measurement Protocol. */ function sendToConversionWorker(type: string, value: number) { try { const adClick = getStoredAdClickId(); - const gaBlocked = typeof (window as any).gtag !== 'function'; - if (!adClick && !gaBlocked) { + if (!adClick && !IMPORTANT_WORKER_EVENTS.has(type)) { return; } - const payload: any = { type, value }; + const payload: any = { + type, + value, + cid: getOrMintClientId(), + sid: getSessionId() + }; if (adClick) { payload.clid = adClick.v; payload.clidKind = adClick.k; } - if (gaBlocked) { - payload.cid = getOrMintClientId(); - payload.sid = getSessionId(); - } const body = JSON.stringify(payload); if (navigator.sendBeacon) { navigator.sendBeacon(CONVERSION_WORKER_URL, body); From 16de99079eff6354377d52160f926a832acb922a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 09:59:22 +0000 Subject: [PATCH 6/6] docs: use the worker's dedicated google-ads and google-analytics endpoints Every event goes to /api/google-ads when a click id is stored, and to /api/google-analytics when no _ga cookie exists (then the worker forwards it to the GA4 Measurement Protocol; users with a _ga cookie report via gtag, so no double counting). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EPJvU6oisC97X9uzc1qfGC --- docs-src/src/components/trigger-event.tsx | 81 ++++++++++------------- 1 file changed, 34 insertions(+), 47 deletions(-) diff --git a/docs-src/src/components/trigger-event.tsx b/docs-src/src/components/trigger-event.tsx index 08cb2bbc580..49f1445a71f 100644 --- a/docs-src/src/components/trigger-event.tsx +++ b/docs-src/src/components/trigger-event.tsx @@ -11,28 +11,13 @@ export type RedditEventType = | 'Lead' | 'Purchase'; -const CONVERSION_WORKER_URL = 'https://rxdb-events.daniel-meyer-e90.workers.dev/api/e'; +const CONVERSION_WORKER_URL = 'https://rxdb-events.daniel-meyer-e90.workers.dev'; /** * Written by storeAdClickId() in Root.tsx when the user lands with a * gclid/gbraid/wbraid URL param. Shape: { k, v, t }. */ export const AD_CLICK_STORAGE_ID = 'click_id'; -/** - * Important conversion events that are ALWAYS sent to the conversion worker, - * even without an ad click id, so they are never lost to ad blockers. - * All other events are sent only when a click id is stored (then Google Ads - * attribution is possible). - */ -export const IMPORTANT_WORKER_EVENTS = new Set([ - 'dev_mode_tracking_iframe', - 'console-log-click', - 'premium_lead', - 'request-demo-sub', - 'copy_on_page', - 'visit_x_urls' -]); - function getStoredAdClickId(): { k: string; v: string; t: number; } | null { try { const raw = localStorage.getItem(AD_CLICK_STORAGE_ID); @@ -50,15 +35,10 @@ function getStoredAdClickId(): { k: string; v: string; t: number; } | null { } /** - * GA4 client id: the real one from the _ga cookie when google analytics - * runs, otherwise a self-minted stable id. The worker uses it to forward - * the event to the GA4 Measurement Protocol. + * Self-minted stable GA4-style client id. Only used when no _ga cookie + * exists, so the worker can forward events to the GA4 Measurement Protocol. */ function getOrMintClientId(): string { - const fromCookie = document.cookie.match(/_ga=GA\d+\.\d+\.(\d+\.\d+)/); - if (fromCookie) { - return fromCookie[1]; - } let cid = localStorage.getItem('worker_cid'); if (!cid) { cid = Math.floor(Math.random() * 1e10) + '.' + Math.floor(Date.now() / 1000); @@ -80,38 +60,45 @@ function getSessionId(): string { } } +function postToWorker(path: string, payload: any) { + const body = JSON.stringify(payload); + const url = CONVERSION_WORKER_URL + path; + if (navigator.sendBeacon) { + navigator.sendBeacon(url, body); + } else { + fetch(url, { method: 'POST', body, keepalive: true }).catch(() => { }); + } +} + /** - * Sends tracking events to the conversion worker, independent of whether - * google analytics is blocked or not: - * - important events are ALWAYS sent, - * - all other events are sent when an ad click id is stored, so Google Ads - * can import them as offline conversions (which event names count, and + * Sends every tracking event to the conversion worker: + * - to /api/google-ads when an ad click id is stored, so Google Ads can + * import the event as an offline conversion (which event names count, and * whether they are primary or secondary, is decided in Google Ads by which - * conversion actions exist). - * The client id is always attached so the worker can forward the event to - * the GA4 Measurement Protocol. + * conversion actions exist), + * - to /api/google-analytics when no google-analytics cookie exists (gtag + * blocked or never loaded), so the worker forwards the event to the GA4 + * Measurement Protocol. Users with a _ga cookie already report via gtag; + * skipping them here prevents double counting. */ function sendToConversionWorker(type: string, value: number) { try { const adClick = getStoredAdClickId(); - if (!adClick && !IMPORTANT_WORKER_EVENTS.has(type)) { - return; - } - const payload: any = { - type, - value, - cid: getOrMintClientId(), - sid: getSessionId() - }; if (adClick) { - payload.clid = adClick.v; - payload.clidKind = adClick.k; + postToWorker('/api/google-ads', { + type, + value, + clid: adClick.v, + clidKind: adClick.k + }); } - const body = JSON.stringify(payload); - if (navigator.sendBeacon) { - navigator.sendBeacon(CONVERSION_WORKER_URL, body); - } else { - fetch(CONVERSION_WORKER_URL, { method: 'POST', body, keepalive: true }).catch(() => { }); + if (!document.cookie.includes('_ga=')) { + postToWorker('/api/google-analytics', { + type, + value, + cid: getOrMintClientId(), + sid: getSessionId() + }); } } catch (err) { console.log('# Error on conversion-worker trigger:');