Spend anomaly alert
Emails you when a campaign spent far more or far less yesterday than it normally does on that weekday, using the same weekday from the previous four weeks as the baseline.
- requires
- Any account. Works with Performance Max and Demand Gen.
- writes
- Nothing. Read-only.
- schedule
- Daily, early morning
- change first
- EMAIL, THRESHOLD, MIN_COST at the top.
// Spend anomaly alert
// Compares yesterday's cost per campaign against the same weekday over the
// previous four weeks, so weekend and weekday patterns do not raise false alarms.
// FROM campaign covers every channel type, including Performance Max and Demand Gen.
// Read-only: it alerts, it does not pause anything.
var THRESHOLD = 0.4; // 0.4 = alert at 40% above or below the baseline
var MIN_COST = 10; // skip campaigns too small to matter
var EMAIL = ''; // leave empty to log only
function main() {
var tz = AdsApp.currentAccount().getTimeZone();
var yesterday = daysAgo(1, tz);
var baseline = [daysAgo(8, tz), daysAgo(15, tz), daysAgo(22, tz), daysAgo(29, tz)];
var today = costByCampaign(yesterday, yesterday);
var past = {};
for (var i = 0; i < baseline.length; i++) {
var day = costByCampaign(baseline[i], baseline[i]);
for (var n1 in day) {
if (!past[n1]) { past[n1] = []; }
past[n1].push(day[n1]);
}
}
var alerts = [];
for (var name in today) {
var samples = past[name] || [];
if (samples.length < 2) { continue; } // too new to have a baseline
var avg = 0;
for (var j = 0; j < samples.length; j++) { avg += samples[j]; }
avg = avg / samples.length;
if (avg === 0) { continue; }
if (avg < MIN_COST && today[name] < MIN_COST) { continue; }
var delta = (today[name] - avg) / avg;
if (Math.abs(delta) >= THRESHOLD) {
alerts.push([name, today[name], avg, delta]);
}
}
if (alerts.length === 0) {
Logger.log('No campaign moved more than ' + (THRESHOLD * 100) + '% on ' + yesterday + '.');
return;
}
alerts.sort(function (a, b) { return Math.abs(b[3]) - Math.abs(a[3]); });
var lines = ['campaign\tyesterday\tbaseline\tchange'];
for (var k = 0; k < alerts.length; k++) {
var a = alerts[k];
lines.push(a[0] + '\t' + a[1].toFixed(2) + '\t' + a[2].toFixed(2) + '\t' +
(a[3] > 0 ? '+' : '') + (a[3] * 100).toFixed(0) + '%');
}
var body = AdsApp.currentAccount().getName() + '\n' +
'Spend on ' + yesterday + ' against the same weekday, previous four weeks.\n\n' +
lines.join('\n');
Logger.log(body);
if (EMAIL) {
MailApp.sendEmail(EMAIL, 'Spend anomaly - ' + AdsApp.currentAccount().getName(), body);
}
}
function costByCampaign(from, to) {
var out = {};
var rows = AdsApp.search(
'SELECT campaign.name, metrics.cost_micros ' +
'FROM campaign ' +
'WHERE segments.date BETWEEN "' + from + '" AND "' + to + '" ' +
' AND campaign.status != "REMOVED"'
);
while (rows.hasNext()) {
var r = rows.next();
out[String(r.campaign.name)] = Number(r.metrics.costMicros) / 1000000;
}
return out;
}
function daysAgo(n, tz) {
var d = new Date();
d.setDate(d.getDate() - n);
return Utilities.formatDate(d, tz, 'yyyy-MM-dd');
}Why the same weekday
Comparing yesterday to the day before it produces an alert every Monday and every Saturday in any account with a normal weekly pattern. After two weeks of that, the alert gets filtered into a folder and stops working. Comparing Tuesday to the last four Tuesdays removes the pattern and leaves only the real movement.
On AdsApp.campaigns()
The selector API skips Performance Max entirely, so a pacing check written with AdsApp.campaigns() silently ignores what is often the largest line in the account. The GAQL FROM campaign resource returns every channel type, which is why this script queries rather than selects.
Tuning the threshold
Start at 0.4. In accounts with few campaigns and steady budgets you can tighten to 0.25. In accounts with many small campaigns, raise MIN_COST rather than the threshold, otherwise you get alerted about a campaign that moved from two to five.
A new campaign has no baseline for the first two weeks and is skipped rather than reported as an anomaly.