PPC campaign anomalies monitoring script (for Google Ads)
How does it work exactly?
The script regularly goes through the campaigns and checks the Cost, CTR and CPA for the given period of time and compares these metrics with the previous period. It checks metrics for 1 day (yesterday), last 7 days and last 28 days. If the difference between the compared time periods exceeds the coefficient that you set during the implementation, the script will send you an email notification.

How to use it?
The reason for creating this script was the need to respond quickly to unexpected situations and fluctuations in Google Ads accounts. E.g. when a campaign stops due to a technical error or, conversely, „goes crazy“ and starts spending significantly more money. When conversion tracking stops working or there is some external change that dramatically changes customers‘ shopping behavior.
Ideally, you will not receive any notification at all. And when you receive an email, you know something is wrong and you need to act quickly.
But by simply setting the parameters, you can adapt the script to your own needs. If you set the script to monitor subtler changes, you can use it as a daily overview of campaign performance, or monitor only weekly or monthly fluctuations…
Script setup and calibration
Here we go. Open Google Ads, go to Tools and Settings > Scripts, add a new script, delete the pre-filled data, and paste the entire script into the box. Don’t forget to allow all permissions and accesses after setting up, sometimes it’s a bit of detective work, because the dialog window will try to convince you not to do so 🙂
/**
* @overview:
* For each campaign that is NOT labeled 'noCampaignAlerts' this script will compare the Spend, CTR and CPA metric with a previous time period.
* In case of big differences (set up by user) it will log an alert and sends an email notification
* Created by Ladislav Vitouš, 2022
* First published on https://www.vitousladislav.cz/
* For support or MMC version write to info@vitousladislav.cz
*/
// Config basic informations
var EMAIL = "set@youremail.com"; //insert your email
var CAMPAIGNLABEL = "noCampaignAlerts"; //campaign level label for campaigns to ignore
// Treshold for over/under delivery of specific metric. For example 2 for spend 2 times higher 0.5 for half the spend
var adSpendOverDelivery = 2; // alert if the spend overdelivery is greater than x times higher than in previous period
var adSpendUnderDelivery = 0.2; // alert if the spend underdelivery is lower than 0.x times the previous value
var cpaOverDelivery = 2.75; // the same for Cost per Conversion
var cpaUnderDelivery = 0.05; // the same for Cost per Conversion
var ctrOverDelivery = 5; // CTR
var ctrUnderDelivery = 0.5; // CTR
var minimalSpend = 200; // Set default minimal spend for checking CPA and CTR metric. If spend for given time range is lower, script won't send an alert for CPA and CTR. If account has a label "Control_xxx", xxx will be used instead.
// Config what metrics and time periods you want to check. Set false for skipping it
var checkDaily = true; // compare yesterday's results with the day before yesterday results
var checkWeekly = true; // compare last 7 days with days 14 to 8
var checkMonthly = true; // compare last 28 days with previous 28 days (56 to 29)
var checkDailyAdSpend = true; // Daily comparison of ad spend
var checkDailyCpa = true; // Daily comparison of CPA
var checkDailyCtr = true; // Daily comparison of CTR
var checkWeeklyAdSpend = true; // Ad spend last 7 days
var checkWeeklyCpa = true;
var checkWeeklyCtr = true;
var checkMonthlyAdSpend = true; // Ad spend last 28 days
var checkMonthlyCpa = true;
var checkMonthlyCtr = true;
function main() {
var campaignAlert = false;
Logger.log("Checking account: " + AdsApp.currentAccount().getName());
Logger.log("Minimal spend: " + minimalSpend);
// if not already created, create label used to Ignore campaigns
// this is necessary for the script to run even if no campaign has been labeled
var labelName;
var labelExists = false;
var labelIterator = AdsApp.labels().get();
while (labelIterator.hasNext()) {
labelName = labelIterator.next().getName();
if (labelName.localeCompare(CAMPAIGNLABEL) == 0) {
labelExists = true;
break;
} else {
labelExists = false;
}
}
if (labelExists == false) {
AdsApp.createLabel(CAMPAIGNLABEL);
Logger.log("labelCreated");
} else {
Logger.log("labelExists");
}
if (AdsApp.labels().withCondition("Name = " + CAMPAIGNLABEL).get().hasNext()) {
var labelId = AdsApp.labels().withCondition("Name = " + CAMPAIGNLABEL).get().next().getId();
}
// Download all the data from Ads report and create an array of campaign metrics
var listOfCampaigns = [];
if (checkDaily) {
var to = lynt_get_date(1, 1);
var from = lynt_get_date(1, 1);
var report = AdsApp.report("SELECT CampaignName, Cost, CostPerConversion, Ctr FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING " + from + "," + to + "");
var rows = report.rows();
while (rows.hasNext()) {
var oneCampaign = rows.next();
var campaign = {
name: oneCampaign.CampaignName,
cost: oneCampaign.Cost,
ctr: oneCampaign.Ctr,
cpa: oneCampaign.CostPerConversion,
timeframe: '1to1'
};
listOfCampaigns.push(campaign);
}
var to = lynt_get_date(2, 1);
var from = lynt_get_date(2, 1);
var report = AdsApp.report("SELECT CampaignName, Cost, CostPerConversion, Ctr FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING " + from + "," + to + "");
var rows = report.rows();
while (rows.hasNext()) {
var oneCampaign = rows.next();
var campaign = {
name: oneCampaign.CampaignName,
cost: oneCampaign.Cost,
ctr: oneCampaign.Ctr,
cpa: oneCampaign.CostPerConversion,
timeframe: '2to2'
};
listOfCampaigns.push(campaign);
}
}
if (checkWeekly) {
var to = lynt_get_date(1, 1);
var from = lynt_get_date(7, 1);
var report = AdsApp.report("SELECT CampaignName, Cost, CostPerConversion, Ctr FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING " + from + "," + to + "");
var rows = report.rows();
while (rows.hasNext()) {
var oneCampaign = rows.next();
var campaign = {
name: oneCampaign.CampaignName,
cost: oneCampaign.Cost,
ctr: oneCampaign.Ctr,
cpa: oneCampaign.CostPerConversion,
timeframe: '7to1'
};
listOfCampaigns.push(campaign);
}
var to = lynt_get_date(8, 1);
var from = lynt_get_date(14, 1);
var report = AdsApp.report("SELECT CampaignName, Cost, CostPerConversion, Ctr FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING " + from + "," + to + "");
var rows = report.rows();
while (rows.hasNext()) {
var oneCampaign = rows.next();
var campaign = {
name: oneCampaign.CampaignName,
cost: oneCampaign.Cost,
ctr: oneCampaign.Ctr,
cpa: oneCampaign.CostPerConversion,
timeframe: '14to8'
};
listOfCampaigns.push(campaign);
}
}
if (checkMonthly) {
var to = lynt_get_date(1, 1);
var from = lynt_get_date(28, 1);
var report = AdsApp.report("SELECT CampaignName, Cost, CostPerConversion, Ctr FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING " + from + "," + to + "");
var rows = report.rows();
while (rows.hasNext()) {
var oneCampaign = rows.next();
var campaign = {
name: oneCampaign.CampaignName,
cost: oneCampaign.Cost,
ctr: oneCampaign.Ctr,
cpa: oneCampaign.CostPerConversion,
timeframe: '28to1'
};
listOfCampaigns.push(campaign);
}
var to = lynt_get_date(29, 1);
var from = lynt_get_date(56, 1);
var report = AdsApp.report("SELECT CampaignName, Cost, CostPerConversion, Ctr FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING " + from + "," + to + "");
var rows = report.rows();
while (rows.hasNext()) {
var oneCampaign = rows.next();
var campaign = {
name: oneCampaign.CampaignName,
cost: oneCampaign.Cost,
ctr: oneCampaign.Ctr,
cpa: oneCampaign.CostPerConversion,
timeframe: '56to29'
};
listOfCampaigns.push(campaign);
}
}
// Let's check the campaigns for anomalities
var campaignSelector = AdsApp.report("SELECT CampaignName FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING YESTERDAY");
var normalCampaignIterator = campaignSelector.rows();
var campaignErrors = [];
CheckCampaigns(normalCampaignIterator);
// THE function that does all the heavy lifting
function CheckCampaigns(campaignIterator) {
while (campaignIterator.hasNext()) {
var campaign = campaignIterator.next();
var nameOfCampaign = campaign.CampaignName;
Logger.log(" Checking Campaign: " + nameOfCampaign);
// Creating variables for previous period for later comparison
if (checkDaily) {
var anticipatedDailyAdSpend = findAMetric(nameOfCampaign, '2to2', 'cost');
var anticipatedDailyCpa = findAMetric(nameOfCampaign, '2to2', 'cpa');
var anticipatedDailyCtr = findAMetric(nameOfCampaign, '2to2', 'ctr');
var anticipatedDailyAdSpend = anticipatedDailyAdSpend.replace(/,|%/g, "");
var anticipatedDailyCpa = anticipatedDailyCpa.replace(/,|%/g, "");
var anticipatedDailyCtr = anticipatedDailyCtr.replace(/,/g, "");
}
if (checkWeekly) {
var anticipatedWeeklyAdSpend = findAMetric(nameOfCampaign, '14to8', 'cost');
var anticipatedWeeklyCpa = findAMetric(nameOfCampaign, '14to8', 'cpa');
var anticipatedWeeklyCtr = findAMetric(nameOfCampaign, '14to8', 'ctr');
var anticipatedWeeklyAdSpend = anticipatedWeeklyAdSpend.replace(/,|%/g, "");
var anticipatedWeeklyCpa = anticipatedWeeklyCpa.replace(/,|%/g, "");
var anticipatedWeeklyCtr = anticipatedWeeklyCtr.replace(/,|%/g, "");
}
if (checkMonthly) {
var anticipatedMonthlyAdSpend = findAMetric(nameOfCampaign, '56to29', 'cost');
var anticipatedMonthlyCpa = findAMetric(nameOfCampaign, '56to29', 'cpa');
var anticipatedMonthlyCtr = findAMetric(nameOfCampaign, '56to29', 'ctr');
var anticipatedMonthlyAdSpend = anticipatedMonthlyAdSpend.replace(/,|%/g, "");
var anticipatedMonthlyCpa = anticipatedMonthlyCpa.replace(/,|%/g, "");
var anticipatedMonthlyCtr = anticipatedMonthlyCtr.replace(/,|%/g, "");
}
// daily check
if (checkDaily) {
var adSpendYesterday = findAMetric(nameOfCampaign, "1to1", "cost");
var adSpendYesterday = adSpendYesterday.replace(/,|%/g, "");
if (checkDailyAdSpend) {
if (adSpendYesterday > (anticipatedDailyAdSpend * adSpendOverDelivery) && anticipatedDailyAdSpend != 0) {
campaignAlert = true;
Logger.log(" DAILY overdelivery spend alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Daily overspend</td><td>" + adSpendYesterday + "</td><td>" + (adSpendYesterday / anticipatedDailyAdSpend).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "1to1", "cost") + "</td></tr>");
}
if (adSpendYesterday < (anticipatedDailyAdSpend * adSpendUnderDelivery) && anticipatedDailyAdSpend != 0) {
campaignAlert = true;
Logger.log(" DAILY underdelivery spend alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Daily underspend</td><td>" + adSpendYesterday + "</td><td>" + (adSpendYesterday / anticipatedDailyAdSpend).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "1to1", "cost") + "</td></tr>");
}
}
if (checkDailyCpa && adSpendYesterday > minimalSpend) {
var cpaYesterday = findAMetric(nameOfCampaign, "1to1", "cpa");
var cpaYesterday = cpaYesterday.replace(/,|%/g, "");
if (cpaYesterday > (anticipatedDailyCpa * cpaOverDelivery) && anticipatedDailyCpa != 0) {
campaignAlert = true;
Logger.log(" DAILY overdelivery CPA alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Daily overCPA</td><td>" + cpaYesterday + "</td><td>" + (cpaYesterday / anticipatedDailyCpa).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "1to1", "cost") + "</td></tr>");
}
if (cpaYesterday < (anticipatedDailyCpa * cpaUnderDelivery) && anticipatedDailyCpa != 0) {
campaignAlert = true;
Logger.log(" DAILY underdelivery CPA alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Daily underCPA</td><td>" + cpaYesterday + "</td><td>" + (cpaYesterday / anticipatedDailyCpa).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "1to1", "cost") + "</td></tr>");
}
}
if (checkDailyCtr && adSpendYesterday > minimalSpend) {
var ctrYesterday = findAMetric(nameOfCampaign, "1to1", "cpa");
var ctrYesterday = ctrYesterday.replace(/,|%/g, "");
if (ctrYesterday > (anticipatedDailyCtr * ctrOverDelivery) && anticipatedDailyCtr != 0) {
campaignAlert = true;
Logger.log(" DAILY overdelivery CTR alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Daily overCTR</td><td>" + ctrYesterday + "</td><td>" + (ctrYesterday / anticipatedDailyCtr).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "1to1", "cost") + "</td></tr>");
}
if (ctrYesterday < (anticipatedDailyCtr * ctrUnderDelivery) && anticipatedDailyCtr != 0) {
campaignAlert = true;
Logger.log(" DAILY underdelivery CTR alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Daily underCTR</td><td>" + ctrYesterday + "</td><td>" + (ctrYesterday / anticipatedDailyCtr).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "1to1", "cost") + "</td></tr>");
}
}
}
// weekly check
if (checkWeekly) {
var adSpendWeekly = findAMetric(nameOfCampaign, "7to1", "cost");
var adSpendWeekly = adSpendWeekly.replace(/,|%/g, "");
if (checkWeeklyAdSpend) {
if (adSpendWeekly > (anticipatedWeeklyAdSpend * adSpendOverDelivery) && anticipatedWeeklyAdSpend != 0) {
campaignAlert = true;
Logger.log(" WEEKLY overdelivery spend alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Weekly overspend</td><td>" + adSpendWeekly + "</td><td>" + (adSpendWeekly / anticipatedWeeklyAdSpend).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "7to1", "cost") + "</td></tr>");
}
if (adSpendWeekly < (anticipatedWeeklyAdSpend * adSpendUnderDelivery) && anticipatedWeeklyAdSpend != 0) {
campaignAlert = true;
Logger.log(" WEEKLY underdelivery spend alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Weekly underspend</td><td>" + adSpendWeekly + "</td><td>" + (adSpendWeekly / anticipatedWeeklyAdSpend).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "7to1", "cost") + "</td></tr>");
}
}
if (checkWeeklyCpa && adSpendWeekly > minimalSpend) {
var cpaWeekly = findAMetric(nameOfCampaign, "7to1", "cpa");
var cpaWeekly = cpaWeekly.replace(/,|%/g, "");
if (cpaWeekly > (anticipatedWeeklyCpa * cpaOverDelivery) && anticipatedWeeklyCpa != 0) {
campaignAlert = true;
Logger.log(" WEEKLY overdelivery CPA alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Weekly overCPA</td><td>" + cpaWeekly + "</td><td>" + (cpaWeekly / anticipatedWeeklyCpa).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "7to1", "cost") + "</td></tr>");
}
if (cpaWeekly < (anticipatedWeeklyCpa * cpaUnderDelivery) && anticipatedWeeklyCpa != 0) {
campaignAlert = true;
Logger.log(" WEEKLY underdelivery CPA alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Weekly underCPA</td><td>" + cpaWeekly + "</td><td>" + (cpaWeekly / anticipatedWeeklyCpa).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "7to1", "cost") + "</td></tr>");
}
}
if (checkWeeklyCtr && adSpendWeekly > minimalSpend) {
var ctrWeekly = findAMetric(nameOfCampaign, "7to1", "ctr");
var ctrWeekly = ctrWeekly.replace(/,|%/g, "");
if (ctrWeekly > (anticipatedWeeklyCtr * ctrOverDelivery) && anticipatedWeeklyCtr != 0) {
campaignAlert = true;
Logger.log(" WEEKLY overdelivery CTR alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Weekly overCTR</td><td>" + ctrWeekly + "</td><td>" + (ctrWeekly / anticipatedWeeklyCtr).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "7to1", "cost") + "</td></tr>");
}
if (ctrWeekly < (anticipatedWeeklyCtr * ctrUnderDelivery) && anticipatedWeeklyCtr != 0) {
campaignAlert = true;
Logger.log(" WEEKLY underdelivery CTR alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Weekly underCTR</td><td>" + ctrWeekly + "</td><td>" + (ctrWeekly / anticipatedWeeklyCtr).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "7to1", "cost") + "</td></tr>");
}
}
}
// 4 weekly check
if (checkMonthly) {
var adSpendMonthly = findAMetric(nameOfCampaign, "28to1", "cost");
var adSpendMonthly = adSpendMonthly.replace(/,|%/g, "");
if (checkMonthlyAdSpend) {
if (adSpendMonthly > (anticipatedMonthlyAdSpend * adSpendOverDelivery) && anticipatedMonthlyAdSpend != 0) {
campaignAlert = true;
Logger.log(" MONTHLY overdelivery spend alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Monthly overspend</td><td>" + adSpendMonthly + "</td><td>" + (adSpendMonthly / anticipatedMonthlyAdSpend).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "28to1", "cost") + "</td></tr>");
}
if (adSpendMonthly < (anticipatedMonthlyAdSpend * adSpendUnderDelivery) && anticipatedMonthlyAdSpend != 0) {
campaignAlert = true;
Logger.log(" MONTHLY underdelivery spend alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Monthly underspend</td><td>" + adSpendMonthly + "</td><td>" + (adSpendMonthly / anticipatedMonthlyAdSpend).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "28to1", "cost") + "</td></tr>");
}
}
if (checkMonthlyCpa && adSpendMonthly > minimalSpend) {
var cpaMonthly = findAMetric(nameOfCampaign, "28to1", "cpa");
var cpaMonthly = cpaMonthly.replace(/,|%/g, "");
if (cpaMonthly > (anticipatedMonthlyCpa * cpaOverDelivery) && anticipatedMonthlyCpa != 0) {
campaignAlert = true;
Logger.log(" MONTHLY overdelivery CPA alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Monthly overCPA</td><td>" + cpaMonthly + "</td><td>" + (cpaMonthly / anticipatedMonthlyCpa).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "28to1", "cost") + "</td></tr>");
}
if (cpaMonthly < (anticipatedMonthlyCpa * cpaUnderDelivery) && anticipatedMonthlyCpa != 0) {
campaignAlert = true;
Logger.log(" MONTHLY underdelivery CPA alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Monthly underCPA</td><td>" + cpaMonthly + "</td><td>" + (cpaMonthly / anticipatedMonthlyCpa).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "28to1", "cost") + "</td></tr>");
}
}
if (checkMonthlyCtr && adSpendMonthly > minimalSpend) {
var ctrMonthly = findAMetric(nameOfCampaign, "28to1", "ctr");
var ctrMonthly = ctrMonthly.replace(/,|%/g, "");
if (ctrMonthly > (anticipatedMonthlyCtr * ctrOverDelivery) && anticipatedMonthlyCtr != 0) {
campaignAlert = true;
Logger.log(" MONTHLY overdelivery CTR alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Monthly overCTR</td><td>" + ctrMonthly + "</td><td>" + (ctrMonthly / anticipatedMonthlyCtr).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "28to1", "cost") + "</td></tr>");
}
if (ctrMonthly < (anticipatedMonthlyCtr * ctrUnderDelivery) && anticipatedMonthlyCtr != 0) {
campaignAlert = true;
Logger.log(" MONTHLY underdelivery CTR alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Monthly underCTR</td><td>" + ctrMonthly + "</td><td>" + (ctrMonthly / anticipatedMonthlyCtr).toFixed(2) * 100 + "%</td><td> Spended: " + findAMetric(nameOfCampaign, "28to1", "cost") + "</td></tr>");
}
}
}
}
}
// prepare email content
var html = [];
html.push(
"<html>",
"<body>",
"<p>Alerts for: ", AdsApp.currentAccount().getName(), "</p>",
"<p>Treshold value: ", minimalSpend, "</p>",
"<p>----------------------------------------------------</p>",
"<p>Issues found: </p>",
"<table>",
"<tr><td><strong>Campaign name</strong></td><td><strong>Issue</strong></td><td><strong>Cost/CPA/CTR</strong></td><td><strong>Change to previous period</strong></td><td><strong>Spended: Amount spended in given time range</strong></td></tr>", campaignErrors.join("\n"), "</table>",
"</body>",
"</html>"
);
// if there is any alert for any of the campaigns , send email
if (campaignAlert) {
var odeslano = MailApp.sendEmail(EMAIL, "Google Ads Campaign Alerts", "", {
htmlBody: html.join("\n")
});
}
// Function that selects the right metric from the array of campaigns
function findAMetric(campaignName, timeFrame, metric) {
for (var i = 0; i < listOfCampaigns.length; i++) {
if (listOfCampaigns[i].name == campaignName && listOfCampaigns[i].timeframe == timeFrame) {
if (metric == 'cost') {
return listOfCampaigns[i].cost;
break;
}
if (metric == 'cpa') {
return listOfCampaigns[i].cpa;
break;
}
if (metric == 'ctr') {
return listOfCampaigns[i].ctr;
break;
}
}
}
}
}
function lynt_format_awql_date(datum) {
return datum.getUTCFullYear() + ("0" + (datum.getUTCMonth() + 1)).slice(-2) + ("0" + datum.getUTCDate()).slice(-2);
}
//Daylight saving time correction
function lynt_DST(datum, offset) {
var yr = datum.getFullYear();
var dst_start = new Date("March 14, " + yr + " 02:00:00");
var dst_end = new Date("November 07, " + yr + " 02:00:00");
var day = dst_start.getDay();
dst_start.setDate(14 - day);
day = dst_end.getDay();
dst_end.setDate(7 - day);
if (datum >= dst_start && datum < dst_end) {
return offset + 1;
} else {
return offset;
}
}
//Getting data for x days back in right format for AWQL query
function lynt_get_date(pocet_dni, zona) {
var minule = new Date();
//kdyz je letni cas, tak o hodinu dele (GMT+1)
var offset = lynt_DST(minule, zona);
minule.setTime(minule.getTime() - (1000 * 60 * 60 * (24) * pocet_dni) + offset);
return lynt_format_awql_date(minule);
}
Let’s set up and calibrate the whole script. At the beginning you will find several variables that need to be set for the script to work properly.
The first is EMAIL. Set the email address you want to receive notifications on. Don’t forget the quotes.
The second is CAMPAIGNLABEL. You can use the default value here. This is a label you can use when you don’t want the script to check a specific campaign. When you tag a campaign in your account with the label, such as “noCampaignAlerts” (without quotes in an account, with them in the script), the script will ignore that campaign. I recommend setting it only for insignificant campaigns, which spend 0 on some days, and this is perfectly normal and fine for them.
Then comes the setting of threshold values for individual metrics and timeframes adSpendOverDelivery, ctrUnderDelivery, etc. Here you set the upper and lower limits for Cost, CPA and CTR. OverDelivery is the upper limit and the coefficient you enter tells how much it must differ from the previous period in order to trigger an alarm. So a value of 10 means „send a notification if the campaign spent 10 times more than in the previous period“. Alternatively, “the CTR was 10 times higher,” etc. For the lower limit – UnderDelivery – you will work with decimal numbers less than 1. So if you want to send a notification when the spending is 10 times lower, you will use the value 0.1.

Another, perhaps the most important parameter is „minimalSpend„. It indicates from what minimum value of spending for the monitored period the script will send notifications. If the campaign spent only 1 dollar, it is likely that its CTR and CPA will be dramatically different than in the previous period. So we want to compare these metrics only when the campaign spends a significant amount of money. I recommend setting this limit to 3-5 times your average CPA. The lower this value, the more sensitive the script will be.
Please note that this treshold only applies to CTR and CPA. Your budget is always checked because, for example, if your campaign cost drops to 0, this can be a critical issue, but 0 is, of course, below the minimum spend treshold. For campaigns where daily zero spending is common, I recommend filtering them out using a label.
Then there are 12 items that you can use to turn off some checks completely. You do this by rewriting the value „true“ to „false“ (without quotes), or 0. You can tell the script that you do not want to compare yesterday and the day before yesterday (checkDaily) or alternatively, that you are not interested in a month-on-month comparison (checkMonthly). Below you can set the behavior for individual metrics as „don’t compare daily CPA changes.“
However, most anomalies caused by daily fluctuations are filtered out by “minimalSpend”, so it is not necessary to turn off, for example, daily CTR comparisons. The overall daily / weekly / monthly setting has priority over individual metrics, so if you set checkWeekly to „false“ and checkWeeklyCpa to „true“, the script will NOT perform a weekly comparison for CPA.
Consider the default settings only as a very loose guide. Test how many alerts you receive each day and set limits to match the average values in your account and use the alerts only when something goes significantly wrong. Otherwise, there is a risk that you will ignore the alerts that you receive on a daily basis and overlook the real problem.
Credits
At this place, I would like to thank a few people:
Hana Kobzová for her valuable comments and help with many essential functions that I did not know or would not have thought of using them.
Lynt.cz for their great AWQL query generator, from which I used functions for time formatting and the daylight saving time transition.
Kurvin Guiste for the tip on how to parse the output from the .report() method.
Jiří Langer for tips and correction of syntax errors.
Nils Rooijmans for his Daily budget overdelivery alert, which did not work for me at all, but served as a framework for writing this script.
Bottom Line
Is there another use-case you would use the script for? Do you think that the script could be written more elegantly? What settings would you recommend? Let me know in the comments below!
I also have an MCC version of the script. If you are interested, write to info@vitousladislav.cz