// 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');
}