Advanced Free

Advanced Anomaly Detector

Receive customised alerts via Slack and Email when anomalies are detected across your Google Ads Account, Campaigns, Labels or Ad Groups.

Updated: 2024-08-01

Advanced Anomaly Detector - New in August 2024!

Receive customised alerts via Slack and Email when anomalies are detected across your Google Ads Account, Campaigns, Labels or Ad Groups.

Set up alerts when:

  • The Account is down (zero impressions)
  • A Campaign's cost spikes
  • An Ad Group's clicks dip suddenly
  • A Campaign Label falls under a set number of conversions

All including customisable:

  • Date ranges
  • Filters
  • Alert thresholds
  • Notification frequency

Slack Notifications in 5 minutes

To enable Slack Notifications you'll need a Webhook. That's a URL Slack provide so the script knows where to send the alerts.

We've written a guide on grabbing that URL. It's dead easy, promise!

How to create a Slack Webhook (to enable Slack Notifications)

Once you've got the URL, add it to the top of the script by editing the SLACK_TEAM_WEBHOOK_URL variable. See the YouTube video at 5:45 for more information.

What if I have suggestions?

Please let me know! Hearing your pain points is the number one way we can make improvements.

Video Walkthrough

The Template

Google Sheets Template

Make a copy of the template, copy your new Sheet's URL, and paste it atop the Script.

The Script

Advanced Anomaly Detector
/**
 * Meet our Advanced Anomaly Detector for Google Ads
 * @author Charles Bannister
 * @version 1.2.0
 * Updates:
 *  - 1.1.0: Added weekend notifications option
 *  - 1.2.0: Made it possible to run without alert settings (leave the Alert Parameters operator blank)
 * Purchased from shabba.io and not for resale or redistribution - thanks!
 * Free updates & support at  https://shabba.io/script/13
**/

// Template: https://docs.google.com/spreadsheets/d/1Fcaq3PGgBpSwTqs-PAWEo2lkP7WgyQ4NTRZjC4yuIhI
// Template: https://docs.google.com/spreadsheets/d/1Fcaq3PGgBpSwTqs-PAWEo2lkP7WgyQ4NTRZjC4yuIhI
// File > Make a copy or visit https://docs.google.com/spreadsheets/d/1Fcaq3PGgBpSwTqs-PAWEo2lkP7WgyQ4NTRZjC4yuIhI/copy
let INPUT_SHEET_URL = "YOUR_SPREADSHEET_URL_HERE";


// Getting a Slack Webhook URL is dead easy
// How-to with screenshots here: https://docs.google.com/document/d/1g1CX6ZRMtmx6KjNTjxMutqZp5vca7ESs-I0ztl0LcEM/edit
const SLACK_TEAM_WEBHOOK_URL = '';


// Google Drive Folder ID
// Add an ID for the folder where you want to store the reports
// If unspecified, the root folder will be used
const FOLDER_ID = '';
let INPUT_TAB_NAME = 'Anomaly Detector';

const SCRIPT_NAME = 'Anomaly Detector';
const SHABBA_SCRIPT_ID = 13;

//If true, the settings and output sheets will be set to "anyone can edit"
const ANYONE_CAN_EDIT_SHEETS = false;


let DEBUG = false;

const SETTINGS_TEMPLATE_URL = 'https://docs.google.com/spreadsheets/d/1EXuhMGTGvtRsq2KBKuPx4aNt_bBp-Q-GRobGaBwGC0Y/edit#gid=828446083';

const APP_ENVIRONMENTS = { 'PRODUCTION': 'PRODUCTION' }
const APP_ENVIRONMENT = APP_ENVIRONMENTS.PRODUCTION;


let SENT_SLACK_NOTIFICATIONS = [];


const ALERT_OPERATOR_ENUMS = {
  'INCREASE': 'Increases by',
  'DECREASE': 'Decreases by',
}

const settingsColumnNumbers = {
  'attributeColumn': 1,
  'attributeOperatorColumn': 2,
  'attributeParamColumn': 3,
  'thresholdMetricColumn_prev': 5,
  'thresholdOperatorColumn_prev': 6,
  'thresholdParamColumn_prev': 7,
  'thresholdMetricColumn_curr': 9,
  'thresholdOperatorColumn_curr': 10,
  'thresholdParamColumn_curr': 11,
  'alertMetricColumn': 13,
  'alertOperatorColumn': 14,
  'alertParamColumn': 15,
}

const reportTypes = {
  'AdGroup': 'AdGroup',
  'Campaign': 'Campaign',
  'Label': 'Label',
  'Account': 'Account',
}

let EMAIL_SENT_TIME_COLUMN_NUMBER;
let SLACK_SENT_TIME_COLUMN_NUMBER;

const SLACK_ADMIN_WEBHOOK_URL = '';


Date.prototype.yyyymmdd = function () {
  let yyyy = this.getFullYear().toString();
  let mm = (this.getMonth() + 1).toString(); // getMonth() is zero-based
  let dd = this.getDate().toString();
  return yyyy + (mm[1] ? mm : "0" + mm[0]) + (dd[1] ? dd : "0" + dd[0]); // padding
};

let LOCAL = false;
if (typeof AdsApp === 'undefined') {
  LOCAL = true;
}

function main() {
  console.log('Starting');
  let settings = scanForAccounts();
  let ids = Object.keys(settings);
  if (DEBUG) { ids = [""] }
  if (ids.length == 0) { console.log('No Rules Specified'); return; }
  if (isMCC()) {
    MccApp.accounts().withIds(ids).withLimit(50).executeInParallel('runRows', 'callBack', JSON.stringify(settings));
  } else {
    settings = settings[Object.keys(settings)[0]]
    for (let rowId in settings) {
      try {
        runScript(settings[rowId]);
      } catch (e) {
        console.error(e)
      }
    }
  }
}

/**
 * 
 * @param {object} settings 
 * @param {object} anomalySettings 
 */
function getEntities(settings, anomalySettings) {
  let afterDataEntities = getAfterEntityData(settings, anomalySettings);
  // console.log(`afterDataEntities: ${JSON.stringify(afterDataEntities)}`)
  if (Object.keys(afterDataEntities).length == 0) {
    return
  }

  let beforeDataEntities = getBeforeEntityData(settings, anomalySettings);
  // console.log(`beforeDataEntities: ${JSON.stringify(beforeDataEntities)}`);

  //now if we're at label level, we need to add up the campaigns which share the same label
  //product objects with the same makeup
  if (settings.REPORT_TYPE === reportTypes.Label) {
    beforeDataEntities = combineDataByLabel(beforeDataEntities, "beforeMetrics")
    afterDataEntities = combineDataByLabel(afterDataEntities, "afterMetrics")
  }

  let entities = combineBeforeAndAfterEntities(beforeDataEntities, afterDataEntities);

  return entities;

}

function getBeforeEntityData(settings, anomalySettings) {
  if (LOCAL) {
    const query = new Query(settings, anomalySettings, true).get();
    // console.log(query);
    return getBeforeEntityDummyData();
  }
  return getEntityData(settings, anomalySettings, true);
}

function getAfterEntityData(settings, anomalySettings) {
  if (LOCAL) {
    const query = new Query(settings, anomalySettings, false).get();
    // console.log(query);
    return getAfterEntityDummyData();
  }
  return getEntityData(settings, anomalySettings, false);
}

function combineBeforeAndAfterEntities(beforeDataEntities, afterDataEntities) {
  let entities = {}

  //pull everything into afterDataEntities object
  //add compare metricics (percententityIdes)
  for (let entityId in afterDataEntities) {
    if (typeof beforeDataEntities[entityId] === "undefined") {
      continue;
    }
    entities[entityId] = {};
    entities[entityId].beforeMetrics = beforeDataEntities[entityId].beforeMetrics;
    entities[entityId].afterMetrics = afterDataEntities[entityId].afterMetrics;
    entities[entityId].differences = {};
    for (let metric in afterDataEntities[entityId].afterMetrics) {
      const afterMetricValue = afterDataEntities[entityId].afterMetrics[metric];
      const beforeMetricValue = beforeDataEntities[entityId].beforeMetrics[metric];
      const difference = returnDifference(afterMetricValue, beforeMetricValue, "percent");
      entities[entityId].differences[metric] = difference;
      entities[entityId]["campaignName"] = afterDataEntities[entityId]["CampaignName"];
      entities[entityId]["adGroupName"] = afterDataEntities[entityId]["AdGroupName"];
    }

  }

  return entities;
}

/**
 * Get the entity (campaigns, ad groups or labels) data
 * as an object for before or after data
 * @param {object} settings 
 * @param {object} anomalySettings 
 * @param {boolean} compareRun 
 */
function getEntityData(settings, anomalySettings, compareRun) {
  const reportData = getReportDataFromReportType(settings.REPORT_TYPE);
  let id = reportData.id;//ID used in the object, will depend on the level
  const metricsKey = compareRun ? "beforeMetrics" : "afterMetrics";

  const query = new Query(settings, anomalySettings, compareRun).get();
  // console.log(`query: ${query}`);
  let report = AdsApp.report(query);
  let rows = report.rows();
  // console.log(`Number of rows: ${rows.totalNumEntities()}`);
  let entities = {};
  while (rows.hasNext()) {
    let row = rows.next();
    // console.log("Compare run? + query: " + compareRun + " - " + row.CampaignName);
    // console.log(`labels: ${row.Labels}`)
    if (row.Labels) {
      row.Labels = row.Labels.replace("[", "").replace("]", "").replace(/["']/g, "")
      row.Labels = row.Labels.split(",")
    }

    const entityId = settings.REPORT_TYPE === reportTypes.account ? "Account" : row[id];

    //for each alert param metric selected, store it
    for (let alertMetric_i in anomalySettings.alertMetrics) {
      let alertMetric = anomalySettings.alertMetrics[alertMetric_i];
      row[alertMetric] = convertMetricToNumber(row[alertMetric]);
      entities[entityId] = entities[entityId] || {};
      entities[entityId][metricsKey] = entities[entityId][metricsKey] || {};
      entities[entityId][metricsKey][alertMetric] = row[alertMetric];
      if (settings.REPORT_TYPE === reportTypes.AdGroup) {
        entities[entityId]["AdGroupName"] = row["AdGroupName"];
      }
      entities[entityId]["CampaignName"] = row["CampaignName"];
      entities[entityId]["Labels"] = row["Labels"];
    }
  }

  for (let name in entities) {
    if (typeof entities[name] == "undefined") {
      delete entities[name]
    }
  }

  return entities;
}

/**
 * If the metric is a string convert it to a number
 * @param {*} metric 
 */
function convertMetricToNumber(metric) {

  function percentageStringToFloat(metric) {
    metric = metric.replace(/%/g, '');
    return parseFloat(metric) / 100;
  }

  if (typeof metric === 'string' && metric.includes('%')) {
    return percentageStringToFloat(metric);
  }

  if (typeof metric === 'string') {
    metric = metric.replace(/,/g, '');
    return parseFloat(metric);
  }

  return metric;
}

/**
 * Filter the entities down to just the alerts which we'll report on
 * @param {object} entities 
 * @param {object} anomalySettings 
 */
function filterEntitiesToAlerts(entities, anomalySettings) {
  //decide what to keep based on alert params
  //  log("afterDataEntities: " + JSON.stringify(afterDataEntities))

  function getOperator(operator) {
    if (operator === ALERT_OPERATOR_ENUMS.INCREASE) {
      return ">"
    }
    if (operator === ALERT_OPERATOR_ENUMS.DECREASE) {
      return "<"
    }
  }

  /**
   * If decreasing the param needs to be a minus number
   * @param {number} param 
   * @param {string} operator
   */
  function getParam(param, operator) {
    if (operator === "<") {
      return -param;
    }
    return param;
  }

  function generateEvalString({ operator, param, difference }) {
    // console.log(`operator: ${operator}`)
    // console.log(`param: ${param}`)
    if (difference == "inf") {
      return `(${10000} ${operator} ${param})`
    }
    return `(${difference} ${operator} ${param})`
  }

  for (let id in entities) {
    let differences = entities[id]["differences"]
    for (let metrixIndex in anomalySettings.alertMetrics) {
      const metric = anomalySettings.alertMetrics[metrixIndex];
      const operator = getOperator(anomalySettings.alertOperators[metrixIndex]);
      const param = getParam(anomalySettings.alertParams[metrixIndex], operator);
      const difference = differences[metric];
      const evalString = generateEvalString({
        operator,
        param,
        difference,
      });

      if (!operator) {
        continue;
      }

      // log("evalString: " + evalString)
      if (!eval(evalString)) {
        delete entities[id]
      }
    }
  }
}

function runScript(settings) {
  log('Script Started');
  const usingDefaultSettings = settings.SETTINGS_URL === "";
  updateSettings(settings);
  addDefaultLog(settings)

  const settingsSpreadsheet = SpreadsheetApp.openByUrl(settings.SETTINGS_URL);
  addEditors(settingsSpreadsheet, settings.controlSheet, settings.LOGS_COLUMN);
  if (!isValidSettingsRow(settings)) {
    return;
  }

  log("Settings: " + JSON.stringify(settings));

  //don't process default settings (from the template sheet)
  if (usingDefaultSettings) {
    console.log("Using default settings. Please update the settings sheet and run the script again.")
    finaliseScript(settings);
    return;
  }

  const anomalySettings = populateAnomalySettings(settings);
  log("anomalySettings: " + JSON.stringify(anomalySettings))
  //have the information from the sheet, now to start grabbing data and making comparisons
  log("Calculating comparisons...")

  let entities = getEntities(settings, anomalySettings);
  // console.log(`entities before filter: ${JSON.stringify(entities)}`);

  // for (let id in entities) {
  //   console.log(`beforeMetrics: ${JSON.stringify(entities[id]['beforeMetrics'])}`)
  //   console.log(`afterMetrics: ${JSON.stringify(entities[id]['afterMetrics'])}`)
  // }
  console.log("Filtering down to alerts")
  filterEntitiesToAlerts(entities, anomalySettings);
  // console.log(`alert entities: ${JSON.stringify(entities)}`);


  const sheetArray = new SheetArray(settings, anomalySettings, entities).createSheetArray();
  if (LOCAL) {
    return;
  }
  if (!entities || Object.keys(entities).length == 0) {
    settings.LOGS.push("No alerts")
    finaliseScript(settings);
    return;
  }


  //create the sheet/folder if it doesn't already exist
  let reportsFolder = getFolder();
  // addEditors(reportsFolder, settings.controlSheet, settings.LOGS_COLUMN);
  addOutputSheetToSettings(settings, reportsFolder);

  //write to sheet
  log("Writing results...")
  writeMetricsToSheet(settings, sheetArray);

  //Send an email with the changes
  settings.htmlLogs = logsToHtml(settings.LOGS)

  if (shouldSend(settings, entities, 'email')) {
    emailSheet(settings);
  }
  if (shouldSend(settings, entities, 'slack')) {
    sendSlackMessage(settings);
  }

  finaliseScript(settings);


  //update settings/control sheet & throw the error if found
  //remove the unsuccessful note we added

  //*****************************************************   END OF MAIN   *****************************************************//
}


/**
 * 
 * @param {object} settings 
 * @param {object} entities 
 * @param {string} type email or slack
 * @returns {boolean} - Should the message be sent
 */
function shouldSend(settings, entities, type) {
  const sendKey = type === "email" ? "SEND_EMAIL" : "SEND_SLACK";
  if (!settings[sendKey]) {
    return false;
  }
  if (DEBUG) {
    return false;
  }
  if (Object.keys(entities).length === 0) {
    return false;
  }
  if (isWeekend() && !settings['WEEKEND_NOTIFICATIONS']) {
    return false;
  }
  const lastSentKey = type === "email" ? "EMAIL_LAST_SENT_TIME" : "SLACK_LAST_SENT_TIME";
  const lastSent = settings[lastSentKey];
  if (isWithinDays(lastSent, settings['NOTIFICATION_FREQUENCY'])) {
    return false;
  }
  return true;
}

function isWeekend() {
  const day = new Date().getDay();
  return day === 0 || day === 6;
}

/**
 * Defaults to false
 * @param {string} date 
 * @param {string} notificationFrequencyDays 
 * @returns boolean
 */
function isWithinDays(date, notificationFrequencyDays) {
  if (!date || !notificationFrequencyDays) {
    return false;
  }
  const notificationFrequencyDaysNumber = parseInt(notificationFrequencyDays);
  const lastSentDate = new Date(date);
  let cutOffDate = new Date();
  cutOffDate.setDate(cutOffDate.getDate() - notificationFrequencyDaysNumber);
  return lastSentDate >= cutOffDate;
}

function addOutputSheetToSettings(settings, reportsFolder) {
  const outputSheetName = 'Anomaly Detector - ' + settings.ALERT_NAME + ' - ' + AdsApp.currentAccount().getName();
  let outputSpreadsheet = getReportSpreadsheet(reportsFolder, outputSheetName, settings.OUTPUT_SHEET_URL);
  if (ANYONE_CAN_EDIT_SHEETS) {
    setSpreadsheetToAnyoneCanEdit(outputSpreadsheet);
  }
  settings.outputSheet = outputSpreadsheet.getActiveSheet();
  settings.OUTPUT_SHEET_URL = outputSpreadsheet.getUrl();
  log("Output: " + settings.OUTPUT_SHEET_URL);
  addEditors(outputSpreadsheet, settings.controlSheet, settings.LOGS_COLUMN);
}

function finaliseScript(settings) {
  settings.LOGS.push("The script ran sucessfully")
  settings.controlSheet.getRange(settings.ROW_NUM, settings.LOGS_COLUMN, 1, 1).setNote("")
  let errorMessage = ""
  updateControlSheet(errorMessage, settings)

  log('Finished');
}


/**
 * Build up the report query from the this.settings
 * That's:
 * 1) The main this.settings sheet this.settings
 * 2) The anomaly this.settings sheet this.settings
 */
class Query {

  constructor(settings, anomalySettings, compareRun) {
    this.settings = settings;
    this.anomalySettings = anomalySettings;
    this.compareRun = compareRun;
  }

  get() {
    const whereString = this._getWhereString();
    const duringString = this._getDuringString();
    const reportData = getReportDataFromReportType(this.settings.REPORT_TYPE);

    let selectString = "SELECT " + this.anomalySettings.allMetrics.join(',');
    if (reportData.columns.length) {
      selectString += ", " + reportData.columns.join(",");
    }
    let query = (selectString +
      " FROM  " + reportData.name +
      whereString +
      duringString);
    query = query.replace("EQUALS", "=")
    query = query.replace("NOT_EQUALS", "!=")
    // console.log(`compareRun: ${this.compareRun}`)
    // console.log(`query: ${query}`)
    return query;
  }

  _getDuringString() {
    const dates = this._getDates();
    if (this.compareRun) {
      return " DURING " + dates.compareStartDate + "," + dates.compareEndDate;
    }
    return " DURING " + dates.startDate + "," + dates.endDate;
  }

  /**
 * Turn days ago into dates (YYYYMMMDD)
 * @return {Object} - Object containing dates
 */
  _getDates() {
    let date = new Date();
    let preStartDate = new Date(date.getTime() - (this.settings.PREVIOUS_PERIOD_START * 24 * 60 * 60 * 1000));
    let preEndDate = new Date(date.getTime() - (this.settings.PREVIOUS_PERIOD_END * 24 * 60 * 60 * 1000));
    let postStartDate = new Date(date.getTime() - (this.settings.CURRENT_PERIOD_START * 24 * 60 * 60 * 1000));
    let postEndDate = new Date(date.getTime() - (this.settings.CURRENT_PERIOD_END * 24 * 60 * 60 * 1000));

    let compareStartDate = preStartDate.yyyymmdd();
    let compareEndDate = preEndDate.yyyymmdd();
    let startDate = postStartDate.yyyymmdd();
    let endDate = postEndDate.yyyymmdd();
    return {
      compareStartDate,
      compareEndDate,
      startDate,
      endDate
    }
  }

  _getWhereString() {
    let hasAttributes = this.anomalySettings.attributes.length > 0 ? true : false
    let hasthresholdMetricsPrevious = this.anomalySettings.thresholdMetricsPrevious.length > 0 ? true : false
    let hasthresholdMetricsCurrent = this.anomalySettings.thresholdMetricsCurrent.length > 0 ? true : false
    let whereString = "WHERE ";
    if (this.settings.REPORT_TYPE === reportTypes.AdGroup || this.settings.REPORT_TYPE === reportTypes.Campaign) {
      whereString += " CampaignStatus = ENABLED  "
    }
    if (this.settings.REPORT_TYPE === reportTypes.AdGroup) {
      whereString += " AND AdGroupStatus = ENABLED "
    }
    for (let i in this.anomalySettings.attributes) {
      whereString += this._getWhereOperator(whereString);
      whereString += this.anomalySettings.attributes[i] + " "
      whereString += this.anomalySettings.attributeOperators[i] + " '"
      whereString += this.anomalySettings.attributeParams[i] + "' "
      // whereString += (this.anomalySettings.attributes.length - 1).toFixed(0) > i ? " AND " : " "
    }

    if (hasthresholdMetricsCurrent && !this.compareRun) {
      for (let i in this.anomalySettings.thresholdMetricsCurrent) {
        whereString += this._getWhereOperator(whereString);
        whereString += this.anomalySettings.thresholdMetricsCurrent[i] + " "
        whereString += this.anomalySettings.thresholdOperatorsCurrent[i] + " "
        whereString += this.anomalySettings.thresholdParamsCurrent[i] + " "
        // whereString += (this.anomalySettings.thresholdMetricsCurrent.length - 1).toFixed(0) > i ? " AND " : " "
      }
    }

    if (hasthresholdMetricsPrevious && this.compareRun) {
      for (let i in this.anomalySettings.thresholdMetricsPrevious) {
        whereString += this._getWhereOperator(whereString);
        whereString += this.anomalySettings.thresholdMetricsPrevious[i] + " "
        whereString += this.anomalySettings.thresholdOperatorsPrevious[i] + " "
        whereString += this.anomalySettings.thresholdParamsPrevious[i] + " "
        // whereString += (this.anomalySettings.thresholdMetricsPrevious.length - 1).toFixed(0) > i ? " AND " : " "
      }
    }
    if (whereString.trim() === "WHERE") {
      whereString = "";
    }
    return whereString;
  }

  _getWhereOperator(whereString) {
    if (whereString.trim() === "WHERE") {
      return "";
    }
    return whereString.indexOf('WHERE') > -1 ? ' AND ' : '';
  }

}


/**
 * Populate the anomaly settings object from the Settings Sheet
 * @param {object} settings 
 */
function populateAnomalySettings(settings) {
  let anomalySettings = LOCAL ? getDummyAnomalySettings() : getSheetAnomalySettings(settings);
  if (anomalySettings.alertMetrics.length == 0) {
    anomalySettings.alertMetrics[0] = 'Clicks';
    anomalySettings.alertOperators[0] = ALERT_OPERATOR_ENUMS.INCREASE;
    anomalySettings.alertParams[0] = '-100';
  }
  return anomalySettings;
}

function getSheetAnomalySettings(settings) {
  let anomalySettings = {
    "thresholdMetricsPrevious": [],
    "thresholdOperatorsPrevious": [],
    "thresholdParamsPrevious": [],
    "thresholdMetricsCurrent": [],
    "thresholdOperatorsCurrent": [],
    "thresholdParamsCurrent": [],
    "attributes": [],
    "attributeOperators": [],
    "attributeParams": [],
    "alertMetrics": [],
    "alertParams": [],
    "alertOperators": [],
  }

  let moneyMetrics = ["TargetCpa", "CpvBid", "CpmBid", "CpcBid", "CostPerConversion", "CostPerAllConversion", "Cost", "AverageCpm", "AverageCpc", "AverageCost", "ActiveViewMeasurableCost", "ActiveViewCpm"]
  //threshold (filter) metrics - prev

  let settingsSpreadsheet = SpreadsheetApp.openByUrl(settings.SETTINGS_URL)
  settings.settingsSheet = settingsSpreadsheet.getActiveSheet()
  let row = 4;
  while (settings.settingsSheet.getRange(row, settingsColumnNumbers.thresholdMetricColumn_prev).getValue()) {
    let currentMetric = settings.settingsSheet.getRange(row, settingsColumnNumbers.thresholdMetricColumn_prev).getValue()
    let currentMetricParam = settings.settingsSheet.getRange(row, settingsColumnNumbers.thresholdParamColumn_prev).getValue()

    if (moneyMetrics.indexOf(currentMetric) > -1) {
      currentMetricParam = currentMetricParam * 1000000;
    }

    anomalySettings.thresholdMetricsPrevious.push(currentMetric);
    anomalySettings.thresholdOperatorsPrevious.push(settings.settingsSheet.getRange(row, settingsColumnNumbers.thresholdOperatorColumn_prev).getValue());
    anomalySettings.thresholdParamsPrevious.push(currentMetricParam);
    row++;
  }
  //threshold (filter) metrics - curr
  row = 4;
  while (settings.settingsSheet.getRange(row, settingsColumnNumbers.thresholdMetricColumn_curr).getValue()) {
    let currentMetric = settings.settingsSheet.getRange(row, settingsColumnNumbers.thresholdMetricColumn_curr).getValue()
    let currentMetricParam = settings.settingsSheet.getRange(row, settingsColumnNumbers.thresholdParamColumn_curr).getValue()

    if (moneyMetrics.indexOf(currentMetric) > -1) {
      currentMetricParam = currentMetricParam * 1000000;
    }

    anomalySettings.thresholdMetricsCurrent.push(currentMetric);
    anomalySettings.thresholdOperatorsCurrent.push(settings.settingsSheet.getRange(row, settingsColumnNumbers.thresholdOperatorColumn_curr).getValue());
    anomalySettings.thresholdParamsCurrent.push(currentMetricParam);
    row++;
  }
  //alert metrics
  row = 4;
  while (settings.settingsSheet.getRange(row, settingsColumnNumbers.alertMetricColumn).getValue()) {

    anomalySettings.alertMetrics.push(settings.settingsSheet.getRange(row, settingsColumnNumbers.alertMetricColumn).getValue());
    anomalySettings.alertParams.push(settings.settingsSheet.getRange(row, settingsColumnNumbers.alertParamColumn).getValue());
    anomalySettings.alertOperators.push(settings.settingsSheet.getRange(row, settingsColumnNumbers.alertOperatorColumn).getValue());
    row++;
  }
  //attribute filters
  row = 4;
  while (settings.settingsSheet.getRange(row, settingsColumnNumbers.attributeColumn).getValue()) {
    anomalySettings.attributes.push(settings.settingsSheet.getRange(row, settingsColumnNumbers.attributeColumn).getValue());
    anomalySettings.attributeParams.push(settings.settingsSheet.getRange(row, settingsColumnNumbers.attributeParamColumn).getValue());
    anomalySettings.attributeOperators.push(settings.settingsSheet.getRange(row, settingsColumnNumbers.attributeOperatorColumn).getValue());
    row++;
  }
  let equalIndex = anomalySettings.attributeOperators.indexOf("EQUALS");
  anomalySettings.attributeOperators[equalIndex] = "=";
  equalIndex = anomalySettings.attributeOperators.indexOf("NOT_EQUALS");
  anomalySettings.attributeOperators[equalIndex] = "!=";

  anomalySettings.allMetrics = anomalySettings.thresholdMetricsPrevious.concat(anomalySettings.alertMetrics);
  anomalySettings.allMetrics = anomalySettings.allMetrics.concat(anomalySettings.thresholdMetricsCurrent);
  if (!anomalySettings.allMetrics.length) {
    anomalySettings.allMetrics = ["Impressions"]
  }

  return anomalySettings;
}


function writeMetricsToSheet(settings, sheetArray) {
  settings.outputSheet.clearContents();
  if (sheetArray.length == 0) {
    return;
  }
  settings.outputSheet.getRange(1, 1, sheetArray.length, sheetArray[0].length).setValues(sheetArray);
  //set first row to bold
  settings.outputSheet.getRange(1, 1, 1, sheetArray[0].length).setFontWeight("bold");

  settings.outputSheet.sort(3, false);
}

class SheetArray {
  constructor(settings, anomalySettings, entities) {
    this.settings = settings;
    this.anomalySettings = anomalySettings;
    this.entities = entities;
  }

  createSheetArray() {
    let sheetArray = [];
    const header = this.getHeader();
    sheetArray.push(header);
    this.addRows(sheetArray);
    return sheetArray;
  }

  addRows(sheetArray) {
    for (let entityId in this.entities) {
      let row = [];
      let entity = this.entities[entityId];
      if (this.settings.REPORT_TYPE === reportTypes.AdGroup || this.settings.REPORT_TYPE == reportTypes.Campaign) {
        row.push(entity.campaignName)
      }
      if (this.settings.REPORT_TYPE == reportTypes.AdGroup) {
        row.push(entity.adGroupName)
      }
      for (let metricIndex in this.anomalySettings.alertMetrics) {
        let metricName = this.anomalySettings.alertMetrics[metricIndex];
        let difference = entity.differences[metricName];
        difference = Math.round(difference * 10000) / 100 + "%"
        row.push(entity.beforeMetrics[metricName], entity.afterMetrics[metricName], difference);
      }
      sheetArray.push(row);
    }
  }

  getHeader() {
    let header = [];
    if (this.settings.REPORT_TYPE === reportTypes.AdGroup || this.settings.REPORT_TYPE === reportTypes.Campaign) {
      header.push("Campaign Name");
    }
    if (this.settings.REPORT_TYPE == reportTypes.AdGroup) {
      header.push("Ad Group Name");
    }
    for (let metricIndex in this.anomalySettings.alertMetrics) {
      let beforeString = this.anomalySettings.alertMetrics[metricIndex] + " (before)"
      if (header.indexOf(beforeString) > -1) { continue; }
      header.push(beforeString);
      header.push(this.anomalySettings.alertMetrics[metricIndex] + " (after)");
      header.push(this.anomalySettings.alertMetrics[metricIndex] + " % change");
    }
    return header;

  }
}



function returnFormat(type) {
  let numberFormat = ""
  if (type == "money") {
    numberFormat = "#,##0.00"
  } else if (type == "number") {
    numberFormat = "#,##0.00"
  } else if (type == "int") {
    numberFormat = "#,##0"
  } else if (type == "text") {
    numberFormat = ""
  } else if (type == "percentage") {
    numberFormat = "0.00%"
  } else if (type == "date") {
    numberFormat = "yyyy mmmm"
  } else {
    throw ("Error: number type not recognised, please check the header objects")
  }
  return numberFormat
}

/**
 * Get the report info for the AWQL query
 * @param {string} reportType 
 * @returns {Object} - Object containing report name, columns and id
 */
function getReportDataFromReportType(reportType) {
  let reportData = {};
  if (reportType === reportTypes.AdGroup) {
    reportData.name = " ADGROUP_PERFORMANCE_REPORT ";
    reportData.columns = ["AdGroupName", "CampaignName", "AdGroupId", "Labels"];
    reportData.id = "AdGroupId"
    return reportData;
  }
  if (reportType === reportTypes.Label) {
    reportData.name = " CAMPAIGN_PERFORMANCE_REPORT ";
    reportData.columns = ["CampaignName", "CampaignId", "Labels"];
    reportData.id = "CampaignId"
    return reportData;
  }
  if (reportType === reportTypes.Campaign) {
    reportData.name = " CAMPAIGN_PERFORMANCE_REPORT ";
    reportData.columns = ["CampaignName", "CampaignId"];
    reportData.id = "CampaignName"
    return reportData;
  }
  if (reportType === reportTypes.Account) {
    reportData.name = " ACCOUNT_PERFORMANCE_REPORT ";
    reportData.columns = [];
    reportData.id = ""
    return reportData;
  }
  throw new Error("Error, report type not recognised. Expected one of AdGroup, Label, Account or Campaign but got " + reportType)
}


function updateSettings(settings) {
  if (LOCAL) {
    return;
  }
  settings.controlSheet = SpreadsheetApp.openByUrl(INPUT_SHEET_URL).getSheetByName(INPUT_TAB_NAME)
  settings.LOGS_COLUMN = getLogsColumn(settings.controlSheet)
  settings.LOGS = [];
  settings.settingsSpreadsheet = getSettingsSpreadsheet(settings);
}

function setSpreadsheetToAnyoneCanEdit(spreadsheet) {
  const fileID = spreadsheet.getId();
  const file = DriveApp.getFileById(fileID);
  file.setSharing(DriveApp.Access.ANYONE, DriveApp.Permission.EDIT);
}

/*
* Gets the report file (spreadsheet) for the given Google Ads account in the given folder.
* Creates a new spreadsheet if doesn't exist.
*/
function getSettingsSpreadsheet(settings) {
  const reportsFolder = getFolder();
  const sheetName = `Anomaly Detector Row Settings - ${settings.ALERT_NAME} (${AdsApp.currentAccount().getName()})`;
  if (!settings.SETTINGS_URL) {
    createSettingsSheet(settings, sheetName);
  }
  let spreadsheet = SpreadsheetApp.openByUrl(settings.SETTINGS_URL);
  spreadsheet.rename(sheetName);
  if (ANYONE_CAN_EDIT_SHEETS) {
    setSpreadsheetToAnyoneCanEdit(spreadsheet);
  }
  const file = DriveApp.getFilesByName(sheetName).next();
  try {
    reportsFolder.addFile(file);
  } catch (e) {
    console.error(e);
  }
  return spreadsheet;
}

function createSettingsSheet(settings, sheetName) {
  let spreadsheet = SpreadsheetApp.create(sheetName);
  settings.SETTINGS_URL = spreadsheet.getUrl();
  const templateSheet = SpreadsheetApp.openByUrl(SETTINGS_TEMPLATE_URL).getActiveSheet();
  templateSheet.copyTo(spreadsheet);
  spreadsheet.getSheetByName("copy of Anomaly Detector").setName("Anomaly Detector");
  spreadsheet.deleteSheet(spreadsheet.getSheetByName("Sheet1"))
  settings.controlSheet.getRange(settings.ROW_NUM, settings.LOGS_COLUMN - 2, 1, 1).setValue(settings.SETTINGS_URL);
  settings.LOGS = ["Settings sheet created. Add yours settings and run the script again."];
}

function addDefaultLog(settings) {
  if (LOCAL) {
    return;
  }
  let defaultNote = "Possible reasons include: 1) There was an error (check the logs within Google Ads) 2) The script was stopped before completion"
  settings.controlSheet.getRange(settings.ROW_NUM, settings.LOGS_COLUMN, 1, 1).setValue("The script did not run sucessfully").setNote(defaultNote)
}

function isValidSettingsRow(settings) {
  if (LOCAL) {
    return true;
  }
  if (!settings.FLAG) {
    let msg = "Run Script is not set to 'Yes'"
    settings.LOGS.push(msg)
    log(msg)
    updateControlSheet("", settings)
    return false;
  }

  if (settings.SETTINGS_URL == "") {
    let msg = "No settings sheet URL found"
    settings.LOGS.push(msg)
    updateControlSheet("", settings)
    return false;
  }
  return true;
}




function getHeaderTypes() {

  let headerTypes = {
    'ALERT_NAME': 'normal',
    'ID': 'normal',
    'NAME': 'normal',
    'EMAIL': 'csv',
    'FLAG': 'bool',
    'SUBJECT_PREFIX': 'normal',
    'SEND_EMAIL': 'bool',
    'EMAIL_LAST_SENT_TIME': 'normal',
    'SEND_SLACK': 'bool',
    'SLACK_LAST_SENT_TIME': 'normal',
    'NOTIFICATION_FREQUENCY': 'normal',
    'WEEKEND_NOTIFICATIONS': 'bool',
    'REPORT_TYPE': 'normal',
    'PREVIOUS_PERIOD_START': 'normal',
    'PREVIOUS_PERIOD_END': 'normal',
    'CURRENT_PERIOD_START': 'normal',
    'CURRENT_PERIOD_END': 'normal',
    'SETTINGS_URL': 'normal',
    'OUTPUT_SHEET_URL': 'normal'
  }
  return headerTypes;
}

function scanForAccounts() {
  log("getting settings...")
  let map = {};
  let controlSheet = SpreadsheetApp.openByUrl(INPUT_SHEET_URL).getSheetByName(INPUT_TAB_NAME)
  let data = SpreadsheetApp.openByUrl(INPUT_SHEET_URL).getSheetByName(INPUT_TAB_NAME).getDataRange().getValues();
  data.shift();
  data.shift();
  data.shift();

  log(JSON.stringify(data))
  //Run Script?	Level	Start Days Ago	End Days Ago	Start Days Ago	End Days Ago	Settings Sheet	Output
  let headerTypes = getHeaderTypes();

  let header = Object.keys(headerTypes)
  EMAIL_SENT_TIME_COLUMN_NUMBER = header.indexOf("EMAIL_LAST_SENT_TIME") + 1;
  SLACK_SENT_TIME_COLUMN_NUMBER = header.indexOf("SLACK_LAST_SENT_TIME") + 1;

  let LOGS_COLUMN = 0;
  let col = 5
  while (controlSheet.getRange(3, col).getValue()) {
    LOGS_COLUMN = controlSheet.getRange(3, col).getValue() == "Logs" ? col : 0;
    if (LOGS_COLUMN > 0) { break; }
    col++;
  }

  for (let k in data) {
    //if "run script" is not set to "yes", continue.
    if (data[k][header.indexOf("FLAG")] == '' || data[k][header.indexOf("FLAG")].toLowerCase() != 'yes') { continue; }
    let rowNum = parseInt(k, 10) + 4;
    let id = data[k][header.indexOf("ID")];
    let rowId = id + "/" + rowNum;
    map[id] = map[id] || {}
    map[id][rowId] = { 'ROW_NUM': (parseInt(k, 10) + 4) };
    for (let j in header) {
      if (header[j] == "LOGS_COLUMN") {
        map[id][rowId][header[j]] = LOGS_COLUMN;
        continue;
      }
      map[id][rowId][header[j]] = data[k][j];
    }
  }
  log(JSON.stringify(map))
  for (let id in map) {
    for (let rowId in map[id]) {
      for (let key in map[id][rowId]) {
        map[id][rowId][key] = processSetting(key, map[id][rowId][key], headerTypes, controlSheet)
      }
    }
  }
  log(JSON.stringify(map))
  return map;
}

function callBack() {
  // Do something here
  console.log('Finished');
}

function runRows(INPUT) {
  log("running rows")
  settings = JSON.parse(INPUT)[AdsApp.currentAccount().getCustomerId().toString()]
  for (let rowId in settings) {
    runScript(settings[rowId]);
  }
}

function processSetting(key, value, header, controlSheet) {
  let type = header[key]
  if (key == "ROW_NUM") {
    return value;
  }
  switch (type) {
    case "label":
      return [controlSheet.getRange(3, Object.keys(header).indexOf(key) + 1).getValue(), value]
      break;
    case "normal":
      return value
      break;
    case "bool":
      return value == "Yes" ? true : false;
      break;
    case "csv":
      let ret = value.split(",")
      ret = ret[0] == "" && ret.length == 1 ? [] : ret;
      if (ret.length == 0) {
        return [];
      } else {
        for (let r in ret) {
          ret[r] = String(ret[r]).trim()
        }
      }
      return ret;
      break;
    default:
      throw ("error setting type " + type + " not recognised for " + key)

  }
}




function updateControlSheet(errorMessage, settings) {
  let now = Utilities.formatDate(new Date(), AdsApp.currentAccount().getTimeZone(), 'MMM dd, yyyy HH:mm:ss')
  if (errorMessage != "") {
    settings.controlSheet.getRange(settings.ROW_NUM, settings.LOGS_COLUMN, 1, 2).setValues([[errorMessage, now]])
    settings.controlSheet.getRange(settings.ROW_NUM, settings.LOGS_COLUMN, 1, 1).setNote("Note: Some rows running on account ID " + settings.ID + " may not have completed sucessfully. Please see their respective logs and 'Last Run' times.")
    throw (errorMessage)
  }

  //add final logs
  //stringify logs
  settings.LOGS = stringifyLogs(settings.LOGS)
  log("Logs: " + settings.LOGS)
  //update control sheet
  let put = [settings.OUTPUT_SHEET_URL, settings.LOGS, now]
  settings.controlSheet.getRange(settings.ROW_NUM, settings.LOGS_COLUMN - 1, 1, 3).setValues([put])
  settings.controlSheet.getRange(settings.ROW_NUM, settings.LOGS_COLUMN, 1, 1).setNote(settings.LOGS)
}

function logsToHtml(logs) {
  let html = "<ol>"
  for (let l in logs) {
    html += "<li>" + logs[l] + "</li>";
  }
  html += "</ol>"
  return html
}



/*
* Gets the report file (spreadsheet) for the given Google Ads account in the given folder.
* Creates a new spreadsheet if doesn't exist.
*/
function getReportSpreadsheet(folder, sheetName, url) {
  if (url != "" && typeof url != "undefined") {
    //we have a url, use that, but we may want to rename it if anything has changed
    let spreadsheet = SpreadsheetApp.openByUrl(url);
    spreadsheet.rename(sheetName)
    return spreadsheet;
  }

  let spreadsheet = SpreadsheetApp.create(sheetName);
  let file = DriveApp.getFileById(spreadsheet.getId());
  let oldFolder = file.getParents().next();
  folder.addFile(file);
  oldFolder.removeFile(file);

  return spreadsheet;
}
function getFolder() {
  if (FOLDER_ID) {
    return DriveApp.getFolderById(FOLDER_ID);
  }
  return DriveApp.getRootFolder();
}



function stringifyLogs(logs) {
  let s = ""
  for (let l in logs) {
    s += (parseInt(l) + 1) + ") ";
    s += logs[l] + " ";
  }
  return s
}

function round(num, n) {
  return +(Math.round(num + "e+" + n) + "e-" + n);
}

function getLogsColumn(controlSheet) {
  let col = 5
  let LOGS_COLUMN = 0;
  while (String(controlSheet.getRange(3, col).getValue())) {
    LOGS_COLUMN = controlSheet.getRange(3, col).getValue() == "Logs" ? col : 0;
    if (LOGS_COLUMN > 0) { break; }
    col++;
  }
  return LOGS_COLUMN;
}

function createTabs(tabNames, logSS) {
  //attempt to rename
  let logSheets = logSS.getSheets()
  for (let l in logSheets) {
    let logSheet = logSheets[l]
    try {
      logSheet.setName(tabNames[l])
    } catch (e) {

    }
  }
  //attempt to create
  for (let t in tabNames) {
    let tabName = tabNames[t]

    try {
      logSS.insertSheet(tabName)
    } catch (e) {

    }

  }

}

function addEditors(spreadsheet, controlSheet, LOGS_COLUMN) {

  //check current editors, add if they don't exist
  let currentEditors = spreadsheet.getEditors()
  let currentEditorEmails = []
  for (let c in currentEditors) {
    currentEditorEmails.push(currentEditors[c].getEmail().trim().toLowerCase());
  }

  let editors = controlSheet.getRange(1, LOGS_COLUMN).getValue().trim();
  if (editors == "") { return; }
  editors = editors.split(",").map((editor) => editor.trim().toLowerCase());

  for (let e in editors) {
    if (currentEditorEmails.indexOf(editors[e]) == -1) {
      spreadsheet.addEditor(editors[e])
    }
  }

}
function roundNumber(num, scale) {
  if (!("" + num).indexOf("e") > -1) {
    return +(Math.round(num + "e+" + scale) + "e-" + scale);
  } else {
    let arr = ("" + num).split("e");
    let sig = ""
    if (+arr[1] + scale > 0) {
      sig = "+";
    }
    return +(Math.round(+arr[0] + "e" + sig + (+arr[1] + scale)) + "e-" + scale);
  }
}


function settingsToTable(rowNumber, headerRow, lastColumn, controlSheet) {//settings, the row the headers are on, and the column the settings end at
  let columnTitles = controlSheet.getRange(headerRow, 1, 1, lastColumn).getValues()[0]
  let settings = controlSheet.getRange(rowNumber, 1, 1, lastColumn).getValues()[0]
  let notes = controlSheet.getRange(headerRow, 1, 1, lastColumn).getNotes()[0]
  //create html table
  let table = "<table style='background-color:white;border-collapse:collapse;' border = 1 cellpadding = 5>  <tr>    <th>Setting</th>    <th>Value</th>     <th>Notes</th>  </tr>"
  for (let c in columnTitles) {
    table += "<tr>"
    table += "<td>" + columnTitles[c] + "</td>"
    table += "<td>" + settings[c] + "</td>"
    table += "<td>" + notes[c] + "</td>"
    table += "</tr>"
  }
  table += "</table>"
  return table
}

function emailSheet(settings) {
  log("Sending email")
  const accountName = AdsApp.currentAccount().getName();

  let subject = settings.SUBJECT_PREFIX + ' Anomaly Alert: ' + accountName + " - " + settings.ALERT_NAME + " - " + INPUT_TAB_NAME;
  console.log(`Email subject: ${subject}`);
  let msg = "<div style='line-height:1em;'><p>Hi,</p><p>You have alerts from the anomaly detector for the account '" + accountName + "'.</p><p>"
  if (settings.htmlLogs !== "") {
    msg += "<p>Script logs:</p>"
    msg += settings.htmlLogs
  }
  msg += "<br></br>"
  msg += "<p>The first 30 results are below, all of which are available on the <a href='" + settings.OUTPUT_SHEET_URL + "'>output sheet</a>:</p>"
  //create the table from the output
  let tab = SpreadsheetApp.openByUrl(settings.OUTPUT_SHEET_URL).getActiveSheet()
  let values = tab.getDataRange().getValues().slice(0, 30);

  msg += '<table style="background-color:white;border-collapse:collapse;" border = 1 cellpadding = 5>';
  for (let row = 0; row < values.length; ++row) {
    msg += "<tr>"
    for (let col = 0; col < values[0].length; ++col) {
      if (col < 2) {
        //first two columns are strings
        msg += isNaN(values[row][col]) || values[row][col] == "" ? '<td>' + values[row][col] + '</td>' : '<td>' + String(values[row][col]) + '</td>';
      } else {
        msg += isNaN(values[row][col]) || values[row][col] == "" ? '<td>' + values[row][col] + '</td>' : '<td>' + roundNumber(values[row][col], 2) + '</td>';
      }
    }
    msg += '</tr>';

  }
  msg += '</table><br><br>';
  msg += "<p>The settings for this row can be found on <a href='" + INPUT_SHEET_URL + "'>the control sheet</a> and below:</p><br>"
  msg += settingsToTable(settings.ROW_NUM, 3, settings.LOGS_COLUMN - 1, settings.controlSheet)
  msg += "</div>"
  log("email subject: " + subject)
  // log("email msg: " + msg)
  let emails = settings.EMAIL
  for (let email_i in emails) {
    log("Emailing '" + emails[email_i] + "'")
    MailApp.sendEmail({
      to: emails[email_i],
      subject: subject,
      htmlBody: msg
    });
  }

  settings.controlSheet.getRange(settings['ROW_NUM'], EMAIL_SENT_TIME_COLUMN_NUMBER).setValue(new Date());

}

function sendSlackMessage(settings) {
  const accountName = AdsApp.currentAccount().getName();
  let tab = SpreadsheetApp.openByUrl(settings.OUTPUT_SHEET_URL).getActiveSheet()
  let values = tab.getDataRange().getValues().slice(0, 30);
  const message = `Anomaly Alert:\n
    Name: ${settings.ALERT_NAME}\n
    Subject prefix: ${settings.SUBJECT_PREFIX}\n
    Account name: ${accountName}\n
    First 30 values: ${values}\n
    Logs: ${settings.LOGS}\n
    Control sheet: ${INPUT_SHEET_URL}\n
    Output sheet: ${settings.OUTPUT_SHEET_URL}\n
    `;
  new SlackNotification().sendToTeam(message);
  settings.controlSheet.getRange(settings['ROW_NUM'], SLACK_SENT_TIME_COLUMN_NUMBER).setValue(new Date());
}

/**
 * Aggregate the stats by label
 * @param {object} data 
 * @param {object} metrics 
 * @returns 
 */
function combineDataByLabel(data, metrics) {
  let labels = {}
  for (let id in data) {
    for (let labelIndex in data[id]["Labels"]) {
      let label = data[id]["Labels"][labelIndex];
      if (label === "--") { continue; }
      labels[label] = {}
    }
  }

  for (let id in data) {
    let metricData = data[id][metrics];
    for (let label in labels) {
      for (let metric in metricData) {
        labels[label][metrics] = labels[label][metrics] || {}
        labels[label][metrics][metric] = 0
      }
    }
  }

  for (let id in data) {
    let metricData = data[id][metrics];
    for (let label in labels) {
      for (let metric in metricData) {
        if (data[id]["Labels"].indexOf(label) > -1) {
          labels[label][metrics][metric] += parseFloat(metricData[metric], 10)
        }
      }
    }
  }
  return labels
}

function headersToSheet(settings, campaignName, adGroupName, alertMetrics) {
  log("adding headers to the sheet...")
  let headerValues = []
  if (settings.REPORT_TYPE === reportTypes.Campaign) {
    settings.outputSheet.getRange("A1:A1").setValue("Campaign");

  } else if (settings.REPORT_TYPE === reportTypes.Label) {
    settings.outputSheet.getRange("A1:A1").setValue("Label");

  } else if (settings.REPORT_TYPE == "AdGroup") {
    let headerValues = [[campaignName, adGroupName]];
    settings.outputSheet.getRange("A1:B1").setValues(headerValues);
    headerValues = []
  }

  for (let met_i in alertMetrics) {
    let beforeString = alertMetrics[met_i] + " (before)"
    // log(beforeString, headerValues.indexOf(beforeString), headerValues.indexOf(beforeString)>-1)
    if (headerValues.indexOf(beforeString) > -1) { continue; }
    headerValues.push(beforeString);
    headerValues.push(alertMetrics[met_i] + " (after)");
    headerValues.push(alertMetrics[met_i] + " % change");
  }

  let metricHeaders = []; metricHeaders.push(headerValues);
  let rowStart = settings.REPORT_TYPE === reportTypes.Campaign || settings.REPORT_TYPE === reportTypes.Label ? 2 : 3
  settings.outputSheet.getRange(1, rowStart, 1, metricHeaders[0].length).setValues(metricHeaders);
}

function compare(metric, operator, compareMetric) {
  if (operator == ">") {
    if (metric > compareMetric) { return true } else { return false }
  } else if (operator == "<") {
    if (metric < compareMetric) { return true } else { return false }
  }
}

function returnDifference(number, compareNumber, type) {
  //needs to return 100% if that's the case
  //needs to return infitiy if that's the case
  if (number == 0 && compareNumber == 0) { return 0; }
  if (number == "undefined" || compareNumber == "undefined" || number == "NaN" || compareNumber == "NaN") {
    return "undefined";
  } else {
    if (type == "number") {
      return parseFloat(number - compareNumber);
    } else if (type == "percent") {
      return (parseFloat(number) > 0 && parseFloat(compareNumber) == 0) ? "inf" : parseFloat((number - compareNumber) / compareNumber);
    } else {
      return "error, expected either 'percent' or 'number' as type";
    }
  }
}

function decimalAdjust(type, value, exp) {
  // If the exp is undefined or zero...
  if (typeof exp === 'undefined' || +exp === 0) {
    return Math[type](value);
  }
  value = +value;
  exp = +exp;
  // If the value is not a number or the exp is not an integer...
  if (isNaN(value) || !(typeof exp === 'number' && exp % 1 === 0)) {
    return NaN;
  }
  // If the value is negative...
  if (value < 0) {
    return -decimalAdjust(type, -value, exp);
  }
  // Shift
  value = value.toString().split('e');
  value = Math[type](+(value[0] + 'e' + (value[1] ? (+value[1] - exp) : -exp)));
  // Shift back
  value = value.toString().split('e');
  return +(value[0] + 'e' + (value[1] ? (+value[1] + exp) : exp));
}

if (!Math.round10) {
  Math.round10 = function (value, exp) {
    return decimalAdjust('round', value, exp);
  };
}


function getHtmlTable(range) {
  let ss = range.getSheet().getParent();
  let sheet = range.getSheet();
  startRow = range.getRow();
  startCol = range.getColumn();
  lastRow = range.getLastRow();
  lastCol = range.getLastColumn();

  // Read table contents
  let data = range.getValues();

  // Get css style attributes from range
  let fontColors = range.getFontColors();
  let backgrounds = range.getBackgrounds();
  let fontFamilies = range.getFontFamilies();
  let fontSizes = range.getFontSizes();
  let fontLines = range.getFontLines();
  let fontWeights = range.getFontWeights();
  let horizontalAlignments = range.getHorizontalAlignments();
  let verticalAlignments = range.getVerticalAlignments();

  // Get column widths in pixels
  let colWidths = [];
  for (let col = startCol; col <= lastCol; col++) {
    colWidths.push(sheet.getColumnWidth(col));
  }
  // Get Row heights in pixels
  let rowHeights = [];
  for (let row = startRow; row <= lastRow; row++) {
    rowHeights.push(sheet.getRowHeight(row));
  }

  // Future consideration...
  let numberFormats = range.getNumberFormats();

  // Build HTML Table, with inline styling for each cell
  let tableFormat = 'style="border:1px solid black;border-collapse:collapse;text-align:center" border = 1 cellpadding = 5';
  let html = ['<table ' + tableFormat + '>'];
  // Column widths appear outside of table rows
  for (col = 0; col < colWidths.length; col++) {
    html.push('<col width="' + colWidths[col] + '">')
  }
  // Populate rows
  for (row = 0; row < data.length; row++) {
    html.push('<tr height="' + rowHeights[row] + '">');
    for (col = 0; col < data[row].length; col++) {
      // Get formatted data
      let cellText = data[row][col];
      if (cellText instanceof Date) {
        cellText = Utilities.formatDate(
          cellText,
          ss.getSpreadsheetTimeZone(),
          'MMM/d EEE');
      }
      let style = 'style="'
        + 'color: ' + fontColors[row][col] + '; '
        + 'font-family: ' + fontFamilies[row][col] + '; '
        + 'font-size: ' + fontSizes[row][col] + '; '
        + 'font-weight: ' + fontWeights[row][col] + '; '
        + 'background-color: ' + backgrounds[row][col] + '; '
        + 'text-align: ' + horizontalAlignments[row][col] + '; '
        + 'vertical-align: ' + verticalAlignments[row][col] + '; '
        + '"';
      html.push('<td ' + style + '>'
        + cellText
        + '</td>');
    }
    html.push('</tr>');
  }
  html.push('</table>');

  return html.join('');
}

let log = function () {
  let message = ""
  for (let i = 0; i < arguments.length; i++) {
    message += arguments[i];
    if (i < arguments.length - 1) message += ", "
  }
  if (LOCAL) {
    console.log(message)
    return;
  }
  console.log(AdsApp.currentAccount().getName() + ": " + message)
}

function isMCC() {
  try {
    MccApp.accounts();
    return true;
  } catch (e) {
    if (String(e).indexOf("not defined") > -1) {
      return false;
    } else {
      return true;
    }
  }
}

function getBeforeEntityDummyData() {
  return {}
}

function getAfterEntityDummyData() {
  return {}
}

function getDummyAnomalySettings() {
  return {
    "thresholdMetricsPrevious": [],
    "thresholdOperatorsPrevious": [],
    "thresholdParamsPrevious": [],
    "thresholdMetricsCurrent": [],
    "thresholdOperatorsCurrent": [],
    "thresholdParamsCurrent": [],
    "attributes": [],
    "attributeOperators": [],
    "attributeParams": [],
    "alertMetrics": [
    ],
    "alertParams": [
    ],
    "alertOperators": [
    ],
    "allMetrics": [
      "Clicks"
    ]
  }
}

if (LOCAL) {
  const settings = { "ROW_NUM": 4, "ALERT_NAME": "Google Ads Down (Yesterday)", "ID": "111-032-7969", "NAME": "KP", "EMAIL": "charles@adbloomdigital.com, symonds@kitchenprovisions.co.uk", "FLAG": "Yes", "SUBJECT_PREFIX": "Urgent - Google Ads is Down!", "SEND_EMAIL": "Yes", "EMAIL_LAST_SENT_TIME": "2024-07-08T22:41:39.022Z", "SEND_SLACK": "Yes", "SLACK_LAST_SENT_TIME": "2024-07-08T22:14:11.951Z", "NOTIFICATION_FREQUENCY": 2, "REPORT_TYPE": "Account", "PREVIOUS_PERIOD_START": 14, "PREVIOUS_PERIOD_END": 14, "CURRENT_PERIOD_START": 1, "CURRENT_PERIOD_END": 1, "SETTINGS_URL": "https://docs.google.com/spreadsheets/d/16jqLAPGr9yRL6zT-TtuFcvc5IlBAIVfAHsCv_HLZq8w/edit", "OUTPUT_SHEET_URL": "https://docs.google.com/spreadsheets/d/1WJUigdP4XhIUziLKvatuo78w4nbILGpJ5sAg873me5g/edit" }
  runScript(settings);
}





class SlackNotification {

  constructor() {
    try {
      this.teamWebhookUrl = SLACK_TEAM_WEBHOOK_URL;
      this.adminWebhookUrl = SLACK_ADMIN_WEBHOOK_URL;

      if (typeof SENT_SLACK_NOTIFICATIONS === "undefined") {
        this.sendToAdmin(`SENT_SLACK_NOTIFICATIONS array not defined for '${AdsApp.getExecutionInfo().getScriptName()}' script `);
      }

    } catch (e) {
      this._logError(e);
    }
  }

  sendToTeam(text) {
    try {
      this._sendTeamMessage(text);
    } catch (e) {
      this._logError(e);
      this.sendToAdmin(`Error sending slack message to team: ${e}`);
    }
  }

  sendToAdmin(text) {
    try {
      this._sendAdminMessage(text);
    } catch (e) {
      this._logError(e);
    }
  }

  _sendTeamMessage(text) {
    if (APP_ENVIRONMENT !== APP_ENVIRONMENTS.PRODUCTION) {
      console.warn(`Team notifications won't be sent outside of the production environment`)
      return;
    }
    if (!this.teamWebhookUrl) {
      return;
    }
    this._send(text, this.teamWebhookUrl);
  }

  _sendAdminMessage(text) {
    if (typeof APP_ENVIRONMENT !== "undefined" && APP_ENVIRONMENT !== APP_ENVIRONMENTS.PRODUCTION) {
      text = `*Sending from ${APP_ENVIRONMENT}*\n ${text}`
    }
    this._send(text, this.adminWebhookUrl);
  }

  _send(text, webhookUrl) {
    const scriptName = AdsApp.getExecutionInfo().getScriptName();
    text = `Message from the '${scriptName}' script:\n${text}`;
    try {
      this._sendSlackMessage(text, webhookUrl);
    } catch (e) {
      this._logError(e);
    }
  }

  _sendSlackMessage(text, webhookUrl) {
    if (SENT_SLACK_NOTIFICATIONS.includes(text)) {
      return;
    }
    const slackMessage = {
      text: text,
      icon_url:
        'https://www.gstatic.com/images/icons/material/product/1x/adwords_64dp.png',
      username: 'Google Ads Scripts',
    };

    const options = {
      method: 'POST',
      contentType: 'application/json',
      payload: JSON.stringify(slackMessage)
    };
    UrlFetchApp.fetch(webhookUrl, options);

    SENT_SLACK_NOTIFICATIONS.push(text);
  }

  _logError(error) {
    console.warn('Could not send slack notification. The script logic will not be affected. Here is the error:')
    console.warn(error);
  }

}

How to Use

  1. Copy the script using the button above
  2. Open your Google Ads account
  3. Go to Tools & Settings → Bulk Actions → Scripts
  4. Click the + button to create a new script
  5. Paste the code and click Authorize
  6. Configure any settings at the top of the script
  7. Click Preview to test, then Run when ready