/** * SES GA4 property setup, for the SES Analytics Kit v1.0.0 * Creates a client's GA4 property and website stream with the agency's standard settings, * then prints the Measurement ID for the site's head snippet. * * ONE-TIME SETUP (about 3 minutes) * 1. Sign in to Google with the login that has Editor or Administrator access to your * clients' Analytics accounts. * 2. Go to script.google.com > New project. Name it "SES GA4 setup". * 3. Paste this whole file over everything in Code.gs and save. * 4. Project Settings (cog icon) > tick "Show appsscript.json manifest file in editor". * Back in the Editor, open appsscript.json and replace its contents with the kit's * setup/appsscript.json. Save. * 5. In the Editor's left bar: Services (+) > Google Analytics Admin API > Add. * This switches the API on for this script. * * EACH NEW CLIENT (about 1 minute) * 6. Choose "listAccounts" in the toolbar and press Run. The first run asks you to approve * access. The Execution log lists every account ID you can use. * 7. Fill in CLIENT below, choose "setupClient" and press Run. * 8. Copy the Measurement ID from the Execution log into the site's head snippet. * * It is safe to run twice: it reuses the property, stream, key events and dimensions it finds. */ var CLIENT = { accountId: '', // GA4 account ID from listAccounts, e.g. '123456789' name: '', // property name, e.g. 'Staple Hill MOT Centre' websiteUrl: '', // live address, e.g. 'https://www.staplehillmot.co.uk' industry: 'AUTOMOTIVE', // one of INDUSTRIES below, or '' to leave it blank marketing: false // true only if the client runs Google Ads or Meta ads }; var KEY_EVENTS = [ { eventName: 'generate_lead', countingMethod: 'ONCE_PER_EVENT' }, // every form sent counts { eventName: 'click_call', countingMethod: 'ONCE_PER_SESSION' }, // repeat taps in one visit count once { eventName: 'click_whatsapp', countingMethod: 'ONCE_PER_SESSION' }, { eventName: 'click_email', countingMethod: 'ONCE_PER_SESSION' }, { eventName: 'click_booking', countingMethod: 'ONCE_PER_SESSION' } ]; var CUSTOM_DIMENSIONS = [ { parameterName: 'cta_location', displayName: 'CTA location', scope: 'EVENT', description: 'Where the button was: header, footer, a section, or mobile_bar' }, { parameterName: 'form_name', displayName: 'Form name', scope: 'EVENT', description: 'Which form sent the lead' } ]; var INDUSTRIES = ['AUTOMOTIVE', 'BUSINESS_AND_INDUSTRIAL_MARKETS', 'FINANCE', 'HEALTHCARE', 'TECHNOLOGY', 'TRAVEL', 'OTHER', 'ARTS_AND_ENTERTAINMENT', 'BEAUTY_AND_FITNESS', 'BOOKS_AND_LITERATURE', 'FOOD_AND_DRINK', 'GAMES', 'HOBBIES_AND_LEISURE', 'HOME_AND_GARDEN', 'INTERNET_AND_TELECOM', 'LAW_AND_GOVERNMENT', 'NEWS', 'ONLINE_COMMUNITIES', 'PEOPLE_AND_SOCIETY', 'PETS_AND_ANIMALS', 'REAL_ESTATE', 'REFERENCE', 'SCIENCE', 'SPORTS', 'JOBS_AND_EDUCATION', 'SHOPPING']; var API_ROOT = 'https://analyticsadmin.googleapis.com/'; /* Lists the Analytics accounts this login can use, with their IDs. */ function listAccounts() { var accounts = listAll_('v1beta/accountSummaries', 'accountSummaries'); if (!accounts.length) { Logger.log('No Analytics accounts found for this Google login. Ask the client to add you as an Editor or Administrator.'); return; } accounts.forEach(function (a) { Logger.log(a.account.replace('accounts/', '') + ' ' + a.displayName + ' (' + (a.propertySummaries || []).length + ' properties)'); }); } /* Creates (or completes) the client's property. */ function setupClient() { var c = validate_(CLIENT); var done = []; var failed = []; function step(label, fn) { try { fn(); done.push(label); } catch (e) { failed.push(label + ' - ' + e.message); } } var propertyName = findProperty_(c.accountId, c.name); if (propertyName) { done.push('Using the existing property "' + c.name + '" (' + propertyName + ')'); } else { var body = { parent: 'accounts/' + c.accountId, displayName: c.name, timeZone: 'Europe/London', currencyCode: 'GBP' }; if (c.industry) { body.industryCategory = c.industry; } propertyName = call_('post', 'v1beta/properties', body).name; done.push('Created property "' + c.name + '" (' + propertyName + ')'); } var stream = findWebStream_(propertyName, c.websiteUrl); if (stream) { done.push('Using the existing web stream for ' + ((stream.webStreamData && stream.webStreamData.defaultUri) || c.websiteUrl)); } else { stream = call_('post', 'v1beta/' + propertyName + '/dataStreams', { type: 'WEB_DATA_STREAM', displayName: (c.name + ' website').slice(0, 255), webStreamData: { defaultUri: c.websiteUrl } }); done.push('Created web stream for ' + c.websiteUrl); } var measurementId = stream.webStreamData && stream.webStreamData.measurementId; step('Data retention set to 14 months', function () { call_('patch', 'v1beta/' + propertyName + '/dataRetentionSettings?updateMask=event_data_retention,user_data_retention', { eventDataRetention: 'FOURTEEN_MONTHS', userDataRetention: 'FOURTEEN_MONTHS' }); }); var haveEvents = []; step('Read existing key events', function () { haveEvents = listAll_('v1beta/' + propertyName + '/keyEvents', 'keyEvents').map(function (k) { return k.eventName; }); }); KEY_EVENTS.forEach(function (k) { if (haveEvents.indexOf(k.eventName) > -1) { done.push('Key event already set: ' + k.eventName); return; } step('Key event: ' + k.eventName, function () { call_('post', 'v1beta/' + propertyName + '/keyEvents', k); }); }); var haveDims = []; step('Read existing custom dimensions', function () { haveDims = listAll_('v1beta/' + propertyName + '/customDimensions', 'customDimensions').map(function (x) { return x.parameterName; }); }); CUSTOM_DIMENSIONS.forEach(function (dim) { if (haveDims.indexOf(dim.parameterName) > -1) { done.push('Custom dimension already set: ' + dim.displayName); return; } step('Custom dimension: ' + dim.displayName, function () { call_('post', 'v1beta/' + propertyName + '/customDimensions', dim); }); }); step('Form interaction tracking off (the kit sends generate_lead on a successful send instead)', function () { call_('patch', 'v1alpha/' + stream.name + '/enhancedMeasurementSettings?updateMask=form_interactions_enabled', { formInteractionsEnabled: false }); }); if (!c.marketing) { step('Google signals off', function () { call_('patch', 'v1alpha/' + propertyName + '/googleSignalsSettings?updateMask=state', { state: 'GOOGLE_SIGNALS_DISABLED' }); }); } var propertyId = propertyName.replace('properties/', ''); Logger.log('================================================================'); Logger.log(c.name); Logger.log('MEASUREMENT ID: ' + (measurementId || 'not returned - open Admin > Data streams to copy it')); Logger.log('Property: https://analytics.google.com/analytics/web/#/p' + propertyId + '/reports/intelligenthome'); Logger.log('================================================================'); done.forEach(function (d) { Logger.log('Done: ' + d); }); failed.forEach(function (f) { Logger.log('FAILED: ' + f); }); if (failed.length) { Logger.log('Anything marked FAILED can be set by hand in GA4 Admin, or run setupClient again.'); } Logger.log('Still to do by hand in GA4 Admin:'); Logger.log(' 1. Data streams > [stream] > Configure tag settings > Define internal traffic: add the agency office IP. Then Data settings > Data filters > Internal traffic > Active.'); Logger.log(' 2. Account settings > Data sharing settings: untick everything, unless the client runs Google Ads.'); Logger.log(' 3. Property access management: make sure the client is an Administrator if the account is not already theirs.'); return measurementId; } /* ------------------------------------------------------------------ helpers */ function validate_(c) { var out = { accountId: String(c.accountId || '').replace(/^accounts\//, '').trim(), name: String(c.name || '').trim(), websiteUrl: String(c.websiteUrl || '').trim().replace(/\/+$/, ''), industry: String(c.industry || '').trim().toUpperCase(), marketing: c.marketing === true }; if (!/^\d+$/.test(out.accountId)) { throw new Error('Set CLIENT.accountId to the number shown by listAccounts.'); } if (!out.name || out.name.length > 100) { throw new Error('Set CLIENT.name to the business name (up to 100 characters).'); } if (!/^https?:\/\/[^\s\/]+\.[^\s\/]+/i.test(out.websiteUrl)) { throw new Error('Set CLIENT.websiteUrl to the full live address, starting https://'); } if (out.industry && INDUSTRIES.indexOf(out.industry) === -1) { throw new Error('CLIENT.industry must be one of: ' + INDUSTRIES.join(', ') + ', or empty.'); } return out; } function findProperty_(accountId, name) { var accounts = listAll_('v1beta/accountSummaries', 'accountSummaries'); var account = null; for (var i = 0; i < accounts.length; i++) { if (accounts[i].account === 'accounts/' + accountId) { account = accounts[i]; break; } } if (!account) { throw new Error('This login cannot see account ' + accountId + '. Run listAccounts to check the ID and your access.'); } var want = name.toLowerCase(); var props = (account.propertySummaries || []).filter(function (p) { return String(p.displayName).trim().toLowerCase() === want; }); return props.length ? props[0].property : null; } function findWebStream_(propertyName, websiteUrl) { var streams = listAll_('v1beta/' + propertyName + '/dataStreams', 'dataStreams').filter(function (s) { return s.type === 'WEB_DATA_STREAM'; }); if (!streams.length) { return null; } var host = hostOf_(websiteUrl); for (var i = 0; i < streams.length; i++) { if (hostOf_(streams[i].webStreamData && streams[i].webStreamData.defaultUri) === host) { return streams[i]; } } return streams[0]; } function hostOf_(url) { var m = /^https?:\/\/([^\/?#]+)/i.exec(String(url || '')); return m ? m[1].toLowerCase().replace(/^www\./, '') : ''; } function listAll_(path, key) { var out = []; var token = ''; var guard = 0; do { var r = call_('get', path + (path.indexOf('?') > -1 ? '&' : '?') + 'pageSize=200' + (token ? '&pageToken=' + encodeURIComponent(token) : '')); out = out.concat(r[key] || []); token = r.nextPageToken || ''; guard++; } while (token && guard < 50); return out; } function call_(method, path, body) { var options = { method: method, muteHttpExceptions: true, headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() } }; if (body) { options.contentType = 'application/json'; options.payload = JSON.stringify(body); } var res = UrlFetchApp.fetch(API_ROOT + path, options); var code = res.getResponseCode(); var text = res.getContentText(); var data = {}; try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { raw: text }; } if (code >= 300) { if (code === 403 && /has not been used|is disabled|SERVICE_DISABLED/i.test(text)) { throw new Error('The Google Analytics Admin API is switched off for this script. In the Editor: Services (+) > Google Analytics Admin API > Add, then run again.'); } if (code === 403) { throw new Error('Google refused access (403). Check this login is an Editor or Administrator on the account. Details: ' + ((data.error && data.error.message) || text)); } throw new Error(method.toUpperCase() + ' ' + path.split('?')[0] + ' failed (' + code + '): ' + ((data.error && data.error.message) || text)); } return data; }