Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -13,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
Expand Down
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Empty file added src/events/conversion.ts
Empty file.
74 changes: 74 additions & 0 deletions src/events/pageview.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
47 changes: 47 additions & 0 deletions src/events/pageview.ts
Original file line number Diff line number Diff line change
@@ -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<t.length;++r){var o,n=function(t,e){t=i[t];if(void 0!==t)return e+t}(t[r],"_gcl");!n||(o=function(t){for(var e=t+"=",r=decodeURIComponent(document.cookie).split(";"),o=0;o<r.length;o++){for(var n=r[o];" "==n.charAt(0);)n=n.substring(1);if(0==n.indexOf(e))return[n.substring(e.length,n.length)]}return[]}(n,p.cookie)).length&&(e[n]=o.sort()[o.length-1])}return e}function u(t){for(var e in t)if(t.hasOwnProperty(e))return 1}function l(t,e,r,o){r.href&&(o=k(t,e,r.href,void 0!==o&&o),f.test(o)&&(r.href=o))}function d(t,e,r){if(r&&r.action){var o=(r.method||"").toLowerCase();if("get"===o){for(var n,a=r.childNodes||[],i=!1,c=0;c<a.length;c++){var s=a[c];if(s.name===t){s.setAttribute("value",e),i=!0;break}}i||((n=p.createElement("input")).setAttribute("type","hidden"),n.setAttribute("name",t),n.setAttribute("value",e),r.appendChild(n))}else"post"===o&&(o=k(t,e,r.action),f.test(o)&&(r.action=o))}}function b(t,e,r){for(var o=g,n={},a=0;a<o.length;++a){var i,c,s=o[a];if(i=!r||s.forms)t:{var h=s.domains,f=t,u=!!s.sameHost;if(h&&(u||f!==p.location.hostname))for(var l=0;l<h.length;l++)if(h[l]instanceof RegExp){if(h[l].test(f)){i=!0;break t}}else if(0<=f.indexOf(h[l])||u&&0<=h[l].indexOf(f)){i=!0;break t}i=!1}i&&(null==(c=1)&&(c=1),c===e&&function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])}(n,m()))}return n}function w(t){var e,r=[];for(e in t)if(t.hasOwnProperty(e)){var o=t[e];if(void 0!==o&&o==o&&null!==o&&"[object Object]"!==o.toString()){r.push(e);var n=r,a=n.push,i=String(o);Rf=(o=void 0,o="ABCDEFGHIJKLMNOPQRSTUVWXYZ",(o+="ABCDEFGHIJKLMNOPQRSTUVWXYZ".toLowerCase()+"0123456789-_")+"."),Tf=function(){for(var t=Rf,e={},r=0;r<t.length;++r)e[t[r]]=r;return e}();for(var c=[],s=0;s<i.length;s+=3){var h=s+1<i.length,f=s+2<i.length,u=i.charCodeAt(s),l=h?i.charCodeAt(s+1):0,p=f?i.charCodeAt(s+2):0,g=u>>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<r.length;s++)c=c>>>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<s.length?s[s.length-1]:"").split("/")[0];break;case"fragment":c=t.hash.replace("#","");break;default:c=t&&t.href}return c}(t,e,r,o,n)}function r(t){try{t.action&&c(t,e(function(t){var e=p.createElement("a");t&&(e.href=t);var r=e.pathname;return"/"!==r[0]&&(t||ya("TAGGING",1),r="/"+r),t=e.hostname.replace(h,""),{href:e.href,protocol:e.protocol,host:e.host,hostname:t,pathname:r,search:e.search,hash:e.hash,port:e.port}}(t.action),"host"))}catch(t){}}function o(t){!function(t){try{var e,r;t:{for(var o=t,n=100;o&&0<n;){if(o.href&&o.nodeName.match(/^a(?:rea)?$/i)){e=o;break t}o=o.parentNode,n--}e=null}e&&("http:"!==(r=e.protocol)&&"https:"!==r||c(e,e.hostname))}catch(t){}}(t.target||t.srcElement||{})}t(document,"mousedown",o),t(document,"keyup",o),t(document,"submit",function(t){r(t.target||t.srcElement||{})});var n=HTMLFormElement.prototype.submit;HTMLFormElement.prototype.submit=function(){r(this),n.call(this)}}linker(window,document);`
client.execute(clientJS)
}
Empty file added src/events/remarketing.ts
Empty file.
71 changes: 38 additions & 33 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,11 @@
import { ComponentSettings, Manager, MCEvent } from '@managed-components/types'
import { conversionLinkerHandler, getRandomInt, setGclAwCookie } from './utils'

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
}
import type {
ComponentSettings,
Manager,
MCEvent,
} from '@managed-components/types'
import type { GAdsQuery } from './types'
import { getRandomInt, setGclAwCookie } from './utils'
import { pageViewHandler } from './events/pageview'

export const eventHandler = async (
eventType: string,
Expand All @@ -33,14 +17,6 @@ export const eventHandler = async (
// set the _gcl_aw cookie if _gl or gclid query params exists
setGclAwCookie(client)

// if pageview, run conversion linker if enabled and return
if (eventType === 'pageview') {
if (settings.domains) {
conversionLinkerHandler(client, settings)
}
return
}

// if not pageview, build the request and send it
const query: GAdsQuery = {
guid: 'ON',
Expand Down Expand Up @@ -110,13 +86,42 @@ export const eventHandler = async (
})
}

/**
* Managed Component handler for Google Ads
* @param manager - The Managed Components manager instance
* @param settings - The component settings
* @returns {Promise<void>}
*/
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)
})
Expand Down
25 changes: 25 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Interface for client-side Google Ads calls
* @remarks TODO: Add more details about the properties
*/
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
}
6 changes: 3 additions & 3 deletions src/index.test.ts → src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Expand Down
Loading