The Scripts Desk
Account Anomaly Pulse: compare yesterday with the last four of the same weekday
A read-only Google Ads Script that measures yesterday's cost, clicks and conversions against the average of the same weekday over the previous four weeks, and flags any metric that has drifted past your tolerance.
Most day-on-day alerts cry wolf, because most accounts have a weekly rhythm. A Sunday looks nothing like the Wednesday before it, so comparing yesterday to the day before flags the shape of the week as if it were a problem. The honest comparison is like for like: this Tuesday against the last few Tuesdays. That filters out the weekly pattern and leaves the genuine surprises.
This script reads yesterday's cost, clicks and conversions, then reads the same weekday from one, two, three and four weeks earlier, averages them, and works out how far yesterday sits from that baseline. Any metric outside your tolerance is flagged in the log, and emailed if you set an address. It reads only; nothing in the account is changed. The care here is all in the dates, so the comparison is genuinely like for like and never quietly off by a day.
Before you start
- You need access to the Google Ads account and permission to authorise scripts.
- Decide your tolerance.
0.35means flag a metric that is 35% above or below its four-week weekday average. Tighten it for a stable account, loosen it for a spiky one. - The email is optional. With no address set, the script writes only to the script log.
- A brand new account, or one under four weeks old, has thin baselines. The script uses whatever weekday history exists, but the fewer weeks behind a number, the softer its verdict.
The script
/**
* Account Anomaly Pulse
* The SEM Dispatch Vault, semdispatch.com/vault
*
* Compares yesterday's cost, clicks and conversions with the average of
* the same weekday over the previous four weeks, and flags any metric
* outside the tolerance. Read-only: this script never changes the account.
*/
var CONFIG = {
// Flag a metric this far from its four-week weekday average.
// 0.35 = plus or minus 35%.
TOLERANCE: 0.35,
// Optional. An email address for anomaly alerts, e.g. "you@example.com".
// Leave empty to log only.
EMAIL: ""
};
function main() {
var account = AdsApp.currentAccount();
var tz = account.getTimeZone();
var currency = account.getCurrencyCode();
// Build the dates carefully. We read today's calendar date in the account
// timezone, then rebuild it at UTC noon. Reading and shifting UTC parts of
// a UTC-noon date never rolls into the wrong day at a daylight-saving edge,
// which a naive "now minus 24h" subtraction can do.
var todayParts = Utilities.formatDate(new Date(), tz, "yyyy-MM-dd").split("-");
var anchor = new Date(
Date.UTC(
Number(todayParts[0]),
Number(todayParts[1]) - 1,
Number(todayParts[2]),
12
)
);
var yesterday = shiftDays(anchor, -1);
var baselineDates = [
shiftDays(yesterday, -7),
shiftDays(yesterday, -14),
shiftDays(yesterday, -21),
shiftDays(yesterday, -28)
];
var yesterdayKey = dateKey(yesterday);
var earliest = baselineDates[baselineDates.length - 1];
// One report over the whole span, then we pick out the exact days we need.
var query =
"SELECT segments.date, metrics.cost_micros, metrics.clicks, " +
"metrics.conversions " +
"FROM customer " +
"WHERE segments.date BETWEEN '" + dateKey(earliest) + "' " +
"AND '" + yesterdayKey + "'";
var rows = AdsApp.report(query).rows();
var byDate = {};
while (rows.hasNext()) {
var row = rows.next();
byDate[row["segments.date"]] = {
cost: Number(row["metrics.cost_micros"] || 0) / 1000000,
clicks: Number(row["metrics.clicks"] || 0),
conversions: Number(row["metrics.conversions"] || 0)
};
}
var zero = { cost: 0, clicks: 0, conversions: 0 };
var actual = byDate[yesterdayKey] || zero;
var baselineKeys = baselineDates.map(dateKey);
var metrics = ["cost", "clicks", "conversions"];
var results = [];
for (var m = 0; m < metrics.length; m++) {
var metric = metrics[m];
var sum = 0;
var weeks = 0;
for (var b = 0; b < baselineKeys.length; b++) {
var day = byDate[baselineKeys[b]];
if (day) {
sum += day[metric];
weeks += 1;
}
}
var average = weeks > 0 ? sum / weeks : 0;
var value = actual[metric];
// Deviation against the baseline. With no baseline (a brand new account)
// we cannot judge, so we mark it as such rather than dividing by zero.
var deviation = average > 0 ? (value - average) / average : null;
results.push({
metric: metric,
value: value,
average: average,
weeks: weeks,
deviation: deviation,
flagged: deviation !== null && Math.abs(deviation) > CONFIG.TOLERANCE
});
}
var lines = [
"Account Anomaly Pulse, " + account.getName(),
"Yesterday (" + yesterdayKey + ") vs the previous 4 same-weekdays",
""
];
var anyFlag = false;
for (var r = 0; r < results.length; r++) {
var res = results[r];
var shown =
res.metric === "cost"
? currency + " " + res.value.toFixed(2)
: res.value.toFixed(res.metric === "conversions" ? 1 : 0);
var avgShown =
res.metric === "cost"
? currency + " " + res.average.toFixed(2)
: res.average.toFixed(res.metric === "conversions" ? 1 : 0);
var verdict;
if (res.deviation === null) {
verdict = "no baseline yet";
} else {
var pct = (res.deviation * 100).toFixed(0);
verdict =
(res.deviation >= 0 ? "+" : "") + pct + "% vs avg" +
(res.flagged ? " <-- FLAGGED" : "");
}
if (res.flagged) anyFlag = true;
lines.push(
res.metric.toUpperCase() + ": " + shown +
" (avg " + avgShown + " over " + res.weeks + " wk): " + verdict
);
}
var report = lines.join("\n");
Logger.log(report);
if (CONFIG.EMAIL !== "" && anyFlag) {
MailApp.sendEmail(
CONFIG.EMAIL,
"[Anomaly] " + account.getName() + ": yesterday is off pattern",
report
);
}
}
function shiftDays(date, days) {
var next = new Date(date.getTime());
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function dateKey(date) {
// The dates are constructed in UTC, so format them in UTC to read back the
// same calendar day we built. GAQL's segments.date wants YYYY-MM-DD.
return Utilities.formatDate(date, "UTC", "yyyy-MM-dd");
}
Set it up
- In Google Ads, open Tools, then Bulk actions, then Scripts.
- Create a new script, delete the placeholder, and paste the code above.
- Set
TOLERANCEto taste andEMAILif you want alerts. - Authorise the script when prompted, then use Preview and read the log.
- Schedule it daily, in the morning. Yesterday's data is settled by then, so the comparison is stable.
How to read the pulse
Read the three metrics together before reacting to any one of them. Cost up 40% with clicks up 40% is a demand story, not a fault: something got cheaper or more available and you bought more of it. Cost up 40% with conversions down 40% is the line to act on. Conversions are the noisiest of the three on a low-volume account, so expect that row to flag most often and weigh it against the others.
Two honest limitations worth stating plainly. First, this compares a single day to a four-point average, which is a deliberately blunt instrument: it catches a step change, not a slow drift, and four Tuesdays is a small sample, so a single unusual baseline week can widen the average and hide a real move (or narrow it and cry wolf). It is a morning smoke test, not a statistical model. Second, conversion figures for yesterday can still be maturing if your conversions have a reporting delay, so a "conversions down" flag on a lagged account may simply be conversions that have not landed yet; check again once the window has settled before you treat it as real.