From c78e738657208badd5b62c0ccf315234db85c035 Mon Sep 17 00:00:00 2001 From: Mackenly Jones <53151058+mackenly@users.noreply.github.com> Date: Fri, 23 May 2025 10:37:38 -0400 Subject: [PATCH 1/5] Update README.md --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 917f97d..69e536d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ -# Google Ads Managed Component +# Beta: Google Ads Managed Component + +> [!CAUTION] +> Warning: this is beta software in development that may be buggy, vulnerable, or otherwise not appropriate to use. Use at your own risk. Find out more about Managed Components [here](https://blog.cloudflare.com/zaraz-open-source-managed-components-and-webcm/) for inspiration and motivation details. From 7cb2e367890720ab206d9d20588ad08abbc30f47 Mon Sep 17 00:00:00 2001 From: Mackenly Jones <53151058+mackenly@users.noreply.github.com> Date: Fri, 23 May 2025 11:57:56 -0400 Subject: [PATCH 2/5] Adds info on beta and getting service account for api --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 69e536d..6f295b9 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,33 @@ Find out more about Managed Components [here](https://blog.cloudflare.com/zaraz- 2. Install dependencies with `npm i` 3. Run unit test watcher with `npm run test:dev` +## Google Ads API Service Account + +Since this is a fully server side implementation, you will need to create a service account in your Google Ads account. You can do this by following these steps: + +### 1. Create Service Account +1. Go to Google Cloud Console → IAM & Admin → Service Accounts +2. Create new service account +3. Download the JSON key file +4. Enable Google Ads API for your project + +### 2. Grant Access in Google Ads +1. In Google Ads → Tools → Access and Security → Account Access +2. Add the service account email as a user +3. Grant appropriate permissions (at minimum: Standard access) + +### 3. Extract Key Components for Zaraz +From the JSON key file, you'll need these as separate Zaraz settings: + +- `client_email` +- `private_key` +- `project_id` +- Your Google Ads `customer_id` +- Your `developer_token` (from Google Ads API Center) + +> ![NOTE] +> Need help? [Create an issue](https://github.com/mackenly/google-ads/issues) or post in the [Cloudflare Discord Zaraz channel](https://ptb.discord.com/channels/595317990191398933/917505178016579605) and tag `@mackenly`. + ## ⚙️ Tool Settings > Settings are used to configure the tool in a Component Manager config file From bfcb619b819373bbdead582a54cf140a88a059cb Mon Sep 17 00:00:00 2001 From: Mackenly Jones <53151058+mackenly@users.noreply.github.com> Date: Fri, 23 May 2025 11:58:52 -0400 Subject: [PATCH 3/5] Reorgs files --- src/events/conversion.ts | 0 src/events/pageview.ts | 47 ++++++++++++++++++ src/events/remarketing.ts | 0 src/index.ts | 71 +++++++++++++++------------- src/types.ts | 24 ++++++++++ src/{index.test.ts => utils.test.ts} | 6 +-- src/utils.ts | 25 +++++----- 7 files changed, 125 insertions(+), 48 deletions(-) create mode 100644 src/events/conversion.ts create mode 100644 src/events/pageview.ts create mode 100644 src/events/remarketing.ts create mode 100644 src/types.ts rename src/{index.test.ts => utils.test.ts} (95%) diff --git a/src/events/conversion.ts b/src/events/conversion.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/events/pageview.ts b/src/events/pageview.ts new file mode 100644 index 0000000..4cd810b --- /dev/null +++ b/src/events/pageview.ts @@ -0,0 +1,47 @@ +import type { + ComponentSettings, + MCEvent, + Client, +} from '@managed-components/types' +import { setGclAwCookie } from '../utils' + +/** + * Page view event handler + * @remarks Sets the _gcl_aw cookie if _gl or gclid query params exist, and runs conversion linker if enabled + * @param event - The event object containing client information + * @param settings - The component settings + * @returns void + */ +export async function pageViewHandler( + event: MCEvent, + settings: ComponentSettings +) { + const { client } = event + + // set the _gcl_aw cookie if _gl or gclid query params exists + setGclAwCookie(client) + + // run conversion linker if enabled and return + if (settings.domains) { + conversionLinkerHandler(client, settings) + } +} + +/** + * conversionLinkerHandler + * Sets up the Google Ads conversion linker client-side script + * @param client - The client object used to execute the linker script + * @param settings - The component settings containing a property called domains that is a comma-separated list of domains without spaces in between + * @remarks This function is used to set up the Google Ads conversion linker script on the client side + * @returns void + */ +const conversionLinkerHandler = ( + client: Client, + settings: ComponentSettings +) => { + const clientJS = `function linker(a,p){var g=[{domains:${JSON.stringify( + settings.domains.split(',') + // eslint-disable-next-line no-useless-escape + )},fragment:!1,placement:1,forms:!1,sameHost:!1}],s=/([^?#]+)(\\?[^#]*)?(#.*)?/,h=/:[0-9]+$/,f=/^(?:(?:https?|mailto|ftp):|[^:/?#]*(?:[/?#]|$))/i;var v,i={aw:"_aw",dc:"_dc",gf:"_gf",ha:"_ha",gp:"_gp",gb:"_gb"};function t(t,e,r,o){t.addEventListener?t.addEventListener(e,r,!!o):t.attachEvent&&t.attachEvent("on"+e,r)}function m(){for(var t=["aw","dc","gf","ha","gb"],e={},r=0;r>2,u=(3&u)<<4|l>>4,l=(15&l)<<2|p>>6,p=63&p;f||(p=64,h||(l=64)),c.push(Rf[g],Rf[u],Rf[l],Rf[p])}o=c.join(""),a.call(n,o)}}var m=r.join("*");return["1",function(t,e){var r=[window.navigator.userAgent,(new Date).getTimezoneOffset(),window.navigator.userLanguage||window.navigator.language,Math.floor((new Date).getTime()/60/1e3)-(void 0===e?0:e),t].join("*");if(!(t=v)){for(var o=Array(256),n=0;n<256;n++){for(var a=n,i=0;i<8;i++)a=1&a?a>>>1^3988292384:a>>>1;o[n]=a}t=o}v=t;for(var c=4294967295,s=0;s>>8^v[255&(c^r.charCodeAt(s))];return((-1^c)>>>0).toString(36)}(m),m].join("*")}function k(a,t,e,r){function o(t){var e,r=t,o=new RegExp("(.*?)(^|&)"+a+"=([^&]*)&?(.*)").exec(r),n=r;o&&(e=o[2],r=o[4],n=o[1],r&&(n=n+e+r));n=(t=n).charAt(t.length-1);return t&&"&"!==n&&(t+="&"),t+c}r=void 0!==r&&r;var n=s.exec(e);if(!n)return"";var i=n[1],e=n[2]||"",n=n[3]||"",c=a+"="+t;return r?n="#"+o(n.substring(1)):e="?"+o(e.substring(1)),""+i+e+n}function c(t,e){var r,o="FORM"===(t.tagName||"").toUpperCase(),n=b(e,1,o),a=b(e,2,o),i=b(e,3,o);for(r in u(n)&&(n=w(n),o?d("_gl",n,t):l("_gl",n,t,!1)),!o&&u(a)&&l("_gl",w(a),t,!0),i)if(i.hasOwnProperty(r))t:{var c=r,s=i[r],h=t;if(h.tagName){if("a"===h.tagName.toLowerCase()){l(c,s,h,void 0);break t}if("form"===h.tagName.toLowerCase()){d(c,s,h);break t}}"string"==typeof h&&k(c,s,h,void 0)}}function C(t){return t?t.replace(":","").toLowerCase():""}function e(t,e,r,o,n){return"protocol"!==(e=e&&String(e).toLowerCase())&&"port"!==e||(t.protocol=C(t.protocol)||C(a.location.protocol)),"port"===e?t.port=String(Number((t.hostname?t:a.location).port)||("http"==t.protocol?80:"https"==t.protocol?443:"")):"host"===e&&(t.hostname=(t.hostname||a.location.hostname).replace(h,"").toLowerCase()),function(t,e,r,o,n){var a=C(t.protocol);switch(e=e&&String(e).toLowerCase()){case"url_no_fragment":c=je(t);break;case"protocol":c=a;break;case"host":var i,c=t.hostname.replace(h,"").toLowerCase();!r||(i=/^www\d*\./.exec(c))&&i[0]&&(c=c.substr(i[0].length));break;case"port":c=String(Number(t.port)||("http"==a?80:"https"==a?443:""));break;case"path":t.pathname||t.hostname||ya("TAGGING",1);var s=(c="/"==t.pathname.substr(0,1)?t.pathname:"/"+t.pathname).split("/");0<=Ga(o||[],s[s.length-1])&&(s[s.length-1]=""),c=s.join("/");break;case"query":c=t.search.replace("?",""),n&&(c=fe(c,n,void 0));break;case"extension":s=t.pathname.split(".");c=(c=1} + */ export default async function (manager: Manager, settings: ComponentSettings) { + /** + * Pageview event handler + * @remarks This event is used to load the client side conversion linker + * @see {@link https://support.google.com/tagmanager/answer/7549390?hl=en} + * @param event - The pageview event + * @param settings - The component settings + */ manager.addEventListener('pageview', event => { - eventHandler('pageview', event, settings) + pageViewHandler(event, settings) }) + + /** + * Conversion event handler + * @remarks This event is used for conversion tracking for measuring campaign performance + * @see {@link https://developers.google.com/google-ads/api/rest/reference/rest/v19/customers/uploadClickConversions} + * @param event - The conversion event + * @param settings - The component settings + */ manager.addEventListener('conversion', event => { eventHandler('conversion', event, settings) }) + + /** + * Remarketing event handler + * @remarks This event is used for remarketing purposes + * @see {@link https://developers.google.com/google-ads/api/docs/dynamic-remarketing/overview} + * @param event - The remarketing event + * @param settings - The component settings + */ manager.addEventListener('remarketing', event => { eventHandler('remarketing', event, settings) }) diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..ca9a3fe --- /dev/null +++ b/src/types.ts @@ -0,0 +1,24 @@ +/** + * Interface for client-side Google Ads calls + */ +export interface GAdsQuery { + guid: string + rnd: number + fst: number + cv: number + sendb: number + num: number + u_java: boolean + url: URL | string + tiba?: string + u_tz: number + u_his: number + u_h?: number + u_w?: number + u_ah?: number + u_aw?: number + ig: number + ref?: string + gclaw?: string + gac?: string +} diff --git a/src/index.test.ts b/src/utils.test.ts similarity index 95% rename from src/index.test.ts rename to src/utils.test.ts index 26179e4..821dbdb 100644 --- a/src/index.test.ts +++ b/src/utils.test.ts @@ -1,8 +1,8 @@ -import { Client } from '@managed-components/types' +import type { Client } from '@managed-components/types' import { vi, describe, it, expect, beforeEach } from 'vitest' -import { setGclAwCookie } from './utils' // Adjust this import to your actual function location +import { setGclAwCookie } from './utils' -describe('Google Ads MC', () => { +describe('setGclAwCookie util', () => { beforeEach(() => { vi.clearAllMocks() }) diff --git a/src/utils.ts b/src/utils.ts index c172dc0..659c979 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,20 @@ -import { Client, ComponentSettings } from '@managed-components/types' +import type { Client, ComponentSettings } from '@managed-components/types' +/** + * Get a random integer between min (inclusive) and max (exclusive) + * @param min - The minimum value (inclusive) + * @param max - The maximum value (exclusive) + * @returns A random integer between min and max + */ export const getRandomInt = (min: number, max: number): number => Math.floor(Math.random() * (max - min)) + min +/** + * setGclAwCookie + * Sets cookies for cross-domain tracking and click id + * @param client - The client object used to extract URL and set cookies + * @returns void + */ export function setGclAwCookie(client: Client) { // search for _gl param used for cross-domain tracking, and extract the _gcl_aw cookie value from it try { @@ -38,14 +50,3 @@ export function setGclAwCookie(client: Client) { console.error(e) } } - -export const conversionLinkerHandler = ( - client: Client, - settings: ComponentSettings -) => { - const clientJS = `function linker(a,p){var g=[{domains:${JSON.stringify( - settings.domains.split(',') - // eslint-disable-next-line no-useless-escape - )},fragment:!1,placement:1,forms:!1,sameHost:!1}],s=/([^?#]+)(\\?[^#]*)?(#.*)?/,h=/:[0-9]+$/,f=/^(?:(?:https?|mailto|ftp):|[^:/?#]*(?:[/?#]|$))/i;var v,i={aw:"_aw",dc:"_dc",gf:"_gf",ha:"_ha",gp:"_gp",gb:"_gb"};function t(t,e,r,o){t.addEventListener?t.addEventListener(e,r,!!o):t.attachEvent&&t.attachEvent("on"+e,r)}function m(){for(var t=["aw","dc","gf","ha","gb"],e={},r=0;r>2,u=(3&u)<<4|l>>4,l=(15&l)<<2|p>>6,p=63&p;f||(p=64,h||(l=64)),c.push(Rf[g],Rf[u],Rf[l],Rf[p])}o=c.join(""),a.call(n,o)}}var m=r.join("*");return["1",function(t,e){var r=[window.navigator.userAgent,(new Date).getTimezoneOffset(),window.navigator.userLanguage||window.navigator.language,Math.floor((new Date).getTime()/60/1e3)-(void 0===e?0:e),t].join("*");if(!(t=v)){for(var o=Array(256),n=0;n<256;n++){for(var a=n,i=0;i<8;i++)a=1&a?a>>>1^3988292384:a>>>1;o[n]=a}t=o}v=t;for(var c=4294967295,s=0;s>>8^v[255&(c^r.charCodeAt(s))];return((-1^c)>>>0).toString(36)}(m),m].join("*")}function k(a,t,e,r){function o(t){var e,r=t,o=new RegExp("(.*?)(^|&)"+a+"=([^&]*)&?(.*)").exec(r),n=r;o&&(e=o[2],r=o[4],n=o[1],r&&(n=n+e+r));n=(t=n).charAt(t.length-1);return t&&"&"!==n&&(t+="&"),t+c}r=void 0!==r&&r;var n=s.exec(e);if(!n)return"";var i=n[1],e=n[2]||"",n=n[3]||"",c=a+"="+t;return r?n="#"+o(n.substring(1)):e="?"+o(e.substring(1)),""+i+e+n}function c(t,e){var r,o="FORM"===(t.tagName||"").toUpperCase(),n=b(e,1,o),a=b(e,2,o),i=b(e,3,o);for(r in u(n)&&(n=w(n),o?d("_gl",n,t):l("_gl",n,t,!1)),!o&&u(a)&&l("_gl",w(a),t,!0),i)if(i.hasOwnProperty(r))t:{var c=r,s=i[r],h=t;if(h.tagName){if("a"===h.tagName.toLowerCase()){l(c,s,h,void 0);break t}if("form"===h.tagName.toLowerCase()){d(c,s,h);break t}}"string"==typeof h&&k(c,s,h,void 0)}}function C(t){return t?t.replace(":","").toLowerCase():""}function e(t,e,r,o,n){return"protocol"!==(e=e&&String(e).toLowerCase())&&"port"!==e||(t.protocol=C(t.protocol)||C(a.location.protocol)),"port"===e?t.port=String(Number((t.hostname?t:a.location).port)||("http"==t.protocol?80:"https"==t.protocol?443:"")):"host"===e&&(t.hostname=(t.hostname||a.location.hostname).replace(h,"").toLowerCase()),function(t,e,r,o,n){var a=C(t.protocol);switch(e=e&&String(e).toLowerCase()){case"url_no_fragment":c=je(t);break;case"protocol":c=a;break;case"host":var i,c=t.hostname.replace(h,"").toLowerCase();!r||(i=/^www\d*\./.exec(c))&&i[0]&&(c=c.substr(i[0].length));break;case"port":c=String(Number(t.port)||("http"==a?80:"https"==a?443:""));break;case"path":t.pathname||t.hostname||ya("TAGGING",1);var s=(c="/"==t.pathname.substr(0,1)?t.pathname:"/"+t.pathname).split("/");0<=Ga(o||[],s[s.length-1])&&(s[s.length-1]=""),c=s.join("/");break;case"query":c=t.search.replace("?",""),n&&(c=fe(c,n,void 0));break;case"extension":s=t.pathname.split(".");c=(c=1 Date: Fri, 23 May 2025 11:59:07 -0400 Subject: [PATCH 4/5] Adds tests for pageview --- package-lock.json | 14 +++++++ package.json | 1 + src/events/pageview.test.ts | 74 +++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 src/events/pageview.test.ts diff --git a/package-lock.json b/package-lock.json index 3fdaa5b..136f7ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.3", "license": "Apache-2.0", "devDependencies": { + "@mackenly/zaraz-tools": "^0.3.3", "@managed-components/types": "^1.3.1", "@typescript-eslint/eslint-plugin": "^5.27.0", "all-contributors-cli": "^6.20.0", @@ -547,6 +548,13 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@mackenly/zaraz-tools": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mackenly/zaraz-tools/-/zaraz-tools-0.3.3.tgz", + "integrity": "sha512-H1g2qZySe0cSsM0+eAyOgJKgObc+CJ4hyvuduZBBf625xczDFVA3U21gNvWsZIBpC3HyWP1hDzqdC/4cyFOEjg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@managed-components/types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@managed-components/types/-/types-1.3.1.tgz", @@ -4749,6 +4757,12 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@mackenly/zaraz-tools": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mackenly/zaraz-tools/-/zaraz-tools-0.3.3.tgz", + "integrity": "sha512-H1g2qZySe0cSsM0+eAyOgJKgObc+CJ4hyvuduZBBf625xczDFVA3U21gNvWsZIBpC3HyWP1hDzqdC/4cyFOEjg==", + "dev": true + }, "@managed-components/types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@managed-components/types/-/types-1.3.1.tgz", diff --git a/package.json b/package.json index 1fa8082..2ad0a1e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "homepage": "https://github.com/managed-components/google-ads#readme", "devDependencies": { + "@mackenly/zaraz-tools": "^0.3.3", "@managed-components/types": "^1.3.1", "@typescript-eslint/eslint-plugin": "^5.27.0", "all-contributors-cli": "^6.20.0", diff --git a/src/events/pageview.test.ts b/src/events/pageview.test.ts new file mode 100644 index 0000000..5cd23c5 --- /dev/null +++ b/src/events/pageview.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { pageViewHandler } from './pageview' +import { mockEvent } from '@mackenly/zaraz-tools' +import type { MCEvent, ComponentSettings } from '@managed-components/types' + +describe('pageViewHandler event', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('calls setGclAwCookie with the event client', async () => { + const event: MCEvent = { + ...mockEvent, + client: { + ...mockEvent.client, + url: new URL('https://example.com/?gclid=test-id'), + set: vi.fn(), + execute: vi.fn(), + }, + } as unknown as MCEvent + + const settings: ComponentSettings = { + domains: 'example.com,example.org', + } + + await pageViewHandler(event, settings) + + expect(event.client.set).toHaveBeenCalledWith( + '_gcl_aw', + expect.stringContaining('GCL.'), + { scope: 'infinite' } + ) + }) + + it('calls conversionLinkerHandler if settings.domains is set', async () => { + const settings: ComponentSettings = { + domains: 'example.com,example.org', + } + + const event: MCEvent = { + ...mockEvent, + client: { + ...mockEvent.client, + url: new URL('https://example.com'), + set: vi.fn(), + execute: vi.fn(), + }, + } as unknown as MCEvent + + await pageViewHandler(event, settings) + + expect(event.client.execute).toHaveBeenCalledWith( + expect.stringContaining('linker') + ) + }) + + it('does not call conversionLinkerHandler if settings.domains is not set', async () => { + const event: MCEvent = { + ...mockEvent, + client: { + ...mockEvent.client, + url: new URL('https://example.com'), + set: vi.fn(), + execute: vi.fn(), + }, + } as unknown as MCEvent + + const settings: ComponentSettings = {} + + await pageViewHandler(event, settings) + + expect(event.client.execute).not.toHaveBeenCalled() + }) +}) From 6c71783b2e1a1fc14f61c2136104711f132ccc58 Mon Sep 17 00:00:00 2001 From: Mackenly Jones <53151058+mackenly@users.noreply.github.com> Date: Fri, 23 May 2025 12:01:41 -0400 Subject: [PATCH 5/5] Fix type import --- src/types.ts | 1 + src/utils.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index ca9a3fe..efbb740 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ /** * Interface for client-side Google Ads calls + * @remarks TODO: Add more details about the properties */ export interface GAdsQuery { guid: string diff --git a/src/utils.ts b/src/utils.ts index 659c979..0c5c684 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import type { Client, ComponentSettings } from '@managed-components/types' +import type { Client } from '@managed-components/types' /** * Get a random integer between min (inclusive) and max (exclusive)