What Is Anomaly Detection for Google Ads? (Plus a Free Script That Beats Google's)

By Nicolas Garfinkel · 2026-07-15

Every Google Ads account has bad days. Some of them are normal — a slow Sunday, a holiday lull, a competitor running a one-day promo. Others are expensive emergencies: a billing failure that silently stops serving, a broken conversion tag, a disapproved ad group, a runaway bid change doubling your CPCs. The difference between those two categories can be thousands of dollars, and the only thing separating a two-hour problem from a two-week problem is how fast you notice.

That's what anomaly detection is for. In this post we'll cover what anomaly detection means for Google Ads, how a typical system works, the free options available to you — including Google's official script and a better free script we're releasing today — and why doing this well is harder than it looks.

What is anomaly detection for Google Ads?

Anomaly detection is the practice of continuously comparing your account's current performance against what "normal" looks like, and alerting you when the two diverge by more than routine variance. Instead of you checking dashboards, the system checks them for you — every hour, every day — and only speaks up when something is genuinely off.

A basic account-level system watches four metrics: impressions, clicks, conversions, and cost. The first three alert when they're too low (something stopped working), and cost alerts when it's too high (something is spending out of control). Together they catch the most common failure modes: serving stoppages, tracking breakage, and budget blowouts.

How a typical anomaly detection system works

Nearly every system follows the same three-step loop:

  1. Build a baseline. Pull historical performance for a comparable period — usually the same day of week, since Tuesdays don't behave like Saturdays — and compute what "expected" looks like at the current time of day.
  2. Compare today against it. Take today's cumulative stats through the most recent hour with reliable data and measure how far they sit from the baseline.
  3. Alert when the gap crosses a threshold. If today is far enough from expected, send an email (or Slack message, or ticket) and then avoid re-alerting on the same issue all day.

The design decisions that separate a good system from a noisy one all live inside those three steps: how you compute the baseline, how you define the threshold, and how you handle the messy realities of Google Ads data — reporting delays, conversion lag, partial hours, holidays, and low-volume noise.

The anomaly detection loop
↻ repeats every hour, all day

Option 1: Google's official Account Anomaly Detector

Google publishes a free solution script, the Account Anomaly Detector, and it's a reasonable starting point. Here's how it works.

The script compares stats observed so far today with historical stats for the same day of week. For example, stats for a Tuesday at 1 PM are compared with stats for the 26 previous Tuesdays through 1 PM (the number of weeks to look back is adjustable, depending on the age and stability of your account).

It also accounts for reporting delay. Suppose the script runs at 7 PM on a Tuesday: since Google Ads statistics can be up to three hours delayed, the script only considers stats up to 4 PM. It then fetches stats for the 26 preceding Tuesdays through the same hour, averages them, and compares the result with today's numbers.

You configure the alerting thresholds by hand: alert if impressions or clicks fall below some fraction of the historical average (say, 70%), or if cost rises above some multiple of it. When an alert fires, it emails you and marks a cell in its spreadsheet dashboard; no subsequent alerts of the same type will trigger for the rest of the day. To reset an alert, you delete the "Alerting" cell value in the spreadsheet.

This is simple and easy to understand — but the core logic is an arithmetic average plus a manually chosen percentage threshold, and that combination has real problems in practice.

Where the simple approach breaks down

Averages are fragile. One outlier day in your lookback window — a holiday, a site outage, one viral afternoon — drags the whole baseline up or down, and every future comparison inherits the distortion. And a fixed percentage threshold treats every metric as equally variable: impressions on a large account might rarely swing more than 10%, while conversions on a smaller account can naturally swing 60% day to day. A single "alert below 70% of average" rule is simultaneously too sensitive for volatile metrics and too blind for stable ones.

One freak day drags the average — the median barely notices
Clicks per Tuesday One promo spike Average Median
0 100 200 300 8 Tue ago last Tue Average +20% Median +2% 8 Tuesdays ago: 118 clicks 7 Tuesdays ago: 124 clicks 6 Tuesdays ago: 112 clicks 5 Tuesdays ago: 130 clicks 4 Tuesdays ago: 121 clicks 3 Tuesdays ago: 115 clicks 2 Tuesdays ago: 126 clicks Last Tuesday: 310 clicks (one-day promo) one promo day: 310
Eight Tuesdays of clicks, seven of them near 120. A single 310-click promo day pushes the average from 121 to 145 (+20%) — so every future day gets compared against an inflated "normal." The median moves from 120 to 122.5 (+2%).

Percentages also lie at low volume. "Conversions down 50%" sounds alarming until you realize it means 1 conversion instead of 2. And conversion lag is worse than the general three-hour reporting delay: conversions can keep trickling in for hours or days after the click, so comparing today's still-filling conversion count against fully-backfilled history produces false "conversions are down!" alarms almost every day.

Option 2: a free script that fixes these problems

We built a free, drop-in replacement — the Account Anomaly Detector (Median/MAD Edition) — that keeps the same spirit (runs hourly, same-weekday baseline, spreadsheet state, email alerts) but replaces the fragile statistics with robust ones. The full source is below; here's what it does differently:

CapabilityGoogle's official scriptThis script
Historical centerArithmetic averageMedian
Normal variabilityNot measuredMAD-based normal range
ThresholdManually chosen percentageStatistical boundary (median ± K × MAD)
Low-volume protectionNoneAbsolute-deviation requirement
Outlier resistanceWeakStrong
Conversion delayGeneral three-hour delaySeparate lag-shifted conversion window
Partial-hour protectionGeneral delayed cutoffExplicitly caps at last complete hour
Zero-data detectionNot separately classifiedDedicated serving/pipeline warning
Holiday exclusionsNoYes (ExcludedDates tab)
Account restructure cutoffNoYes (baseline validity floor)
Minimum baseline samplesNo explicit protectionYes
Diagnostic ratiosNoCTR, CPC, CVR, CPA
Likely-cause explanationNoYes, plain-English diagnosis
Historical execution logPrimarily dashboard stateLogs every evaluation
Spreadsheet setupRequires Google's template and named rangesCreates its own tabs

The key ideas, in plain English:

  • Median instead of average. The median of your last 8 Tuesdays is what a typical Tuesday actually looked like. One freak day in the window barely moves it.
  • MAD instead of a guessed percentage. MAD (median absolute deviation) measures how much your account normally bounces around, per metric. The alert boundary becomes "outside the range this account actually lives in," not "below a number you picked once and never revisited."
  • A second, absolute gate. An alert must also deviate by a meaningful absolute amount (e.g., at least 15 clicks or $50). "50% down" on 2 clicks stays silent; "50% down" on 2,000 clicks does not.
  • Conversions get their own lagged window. Conversions are evaluated 12 hours behind everything else, against a baseline computed at the same shifted hour — so slow conversion reporting stops generating daily false alarms.
  • Alerts explain themselves. Every alert email includes CTR / CPC / CVR / CPA ratios versus typical, plus a plain-English likely cause — e.g., "Impressions are down while CTR is normal — a serving-side issue: rank/bids, budget limits, disapprovals, or a paused entity."
How the Median/MAD detector sees a bad Tuesday
Today (cumulative) Typical Tuesday (median) Normal range (median ± 2.5 × MAD) Alert fires
0 100 200 12 AM 4 AM 8 AM Noon 4 PM typical today clicks exit the normal range ALERT → one email, with diagnosis
Today tracks a typical Tuesday until about 8 AM, then flattens — something stopped serving. The moment the cumulative line exits the MAD-based normal range (and the gap is also large in absolute terms), one alert fires. Hover the chart to inspect any hour.

How to set it up (about five minutes)

  1. Create a blank Google Spreadsheet (just sheets.new) and copy its URL. The script builds its own tabs — Status, Log, and ExcludedDates — on first run.
  2. Open Google Ads scripts: in your Google Ads account, go to Tools & Settings → Bulk Actions → Scripts, click the + button, and choose New script.
  3. Paste the entire script below into the editor, replacing the default code (you can also copy it from our GitHub repo).
  4. Update the variables at the top, in the CONFIG block:
    • SPREADSHEET_URL — paste your blank spreadsheet's URL.
    • EMAIL_RECIPIENTS — who should get alerts (comma-separated).
    • LOOKBACK_WEEKS — how many prior same-weekdays to use (8 is a good default; use more for older, stable accounts).
    • MAD_MULTIPLIER — sensitivity; higher means fewer, more severe alerts.
    • MIN_ABSOLUTE_DEVIATION — the smallest deviation worth waking you up for, per metric, tuned to your account's volume.
  5. Authorize and preview. Click Authorize when prompted, then Preview once to confirm it runs cleanly.
  6. Schedule it to run hourly (set the script's frequency to Hourly), and check the box to email you on script failure — that's your free error monitoring.
  7. Optional: add holiday and promo dates to the ExcludedDates tab (one yyyy-MM-dd per row) so they never pollute your baseline.

The script

Copy everything below, or grab the always-current version from our GitHub repository.

/**
 * @name Account Anomaly Detector v1 (Median/MAD Edition)
 *
 * @fileoverview Detects account-level anomalies in Google Ads by comparing
 * today's cumulative performance (through the last hour with reported data)
 * against a robust median/MAD baseline built from the same weekday over the
 * past N weeks. Alerts require BOTH a statistical gate (outside median +/-
 * K * MAD) AND an absolute-deviation gate, which suppresses low-volume noise.
 * Conversions are evaluated on a lag-shifted window. Alerts include
 * diagnostic ratios (CTR / CPC / CVR / CPA) with a plain-English likely cause.
 *
 * Design principles: minimal moving parts, no external dependencies, every
 * tunable in CONFIG at the top. Runs entirely inside Google Ads Scripts.
 *
 * Setup:
 *   1. Create a blank Google Spreadsheet, paste its URL into CONFIG below.
 *      The script creates its own tabs (Status, Log, ExcludedDates) on first
 *      run. Add holiday/promo dates to ExcludedDates column A as yyyy-MM-dd.
 *   2. Schedule the script HOURLY in Google Ads, and check the box to email
 *      you on script failure (this is your free error monitoring).
 *
 * @author Nicolas Garfinkel (TryAdCortex.com)
 * @version 1.1
 *
 * v1.1 changelog:
 *   - FIX: pipeline (zero-data) alert was pushed into a discarded array and
 *     flushEmail was called without it — the email never sent. Now wired.
 *   - FIX: once-per-day dedupe was broken. Sheets coerces 'yyyy-MM-dd'
 *     strings to Date values, so the string comparison in maybeAlert never
 *     matched and anomalies re-alerted every hour. Status column B is now
 *     forced to plain text, and reads normalize Date cells defensively.
 *   - FIX: effectiveCutoff could include the current in-progress hour,
 *     biasing today's cumulative totals low vs. the full-hour baseline
 *     (false "too low" alerts near the top of the hour). Now capped at the
 *     last COMPLETE hour.
 *   - FIX: CVR/CPA diagnostics now use the lag-shifted conversion window
 *     (or print n/a when the window is too small), instead of comparing
 *     lag-underreported today conversions against backfilled history.
 *   - FIX: maybeAlert no longer silently drops alerts for metric keys
 *     missing from the Status tab; it claims an empty row or sends without
 *     dedupe state as a last resort.
 *   - FIX: dateStringDaysAgo anchors to ~noon account time before
 *     subtracting days, so DST transitions can't shift a date boundary on
 *     near-midnight runs.
 *   - ratioLine prints 'n/a' instead of a bogus '+0%' when the expected
 *     denominator is zero.
 */

// ============================== CONFIG =====================================

var CONFIG = {

  // Spreadsheet used for state, logging, and excluded dates.
  SPREADSHEET_URL: 'YOUR_SPREADSHEET_URL',

  // Comma-separated list of alert recipients. Empty string = no email.
  EMAIL_RECIPIENTS: 'you@example.com',

  // Number of prior same-weekdays used to build the baseline.
  LOOKBACK_WEEKS: 8,

  // Statistical gate: alert only if today is outside median +/- K * MAD.
  // Higher = fewer, more severe alerts. 2.5 is a sane default.
  MAD_MULTIPLIER: 2.5,

  // Absolute gate: today must ALSO deviate from the median by at least this
  // much. Protects against "50% down" meaning "1 click instead of 2".
  MIN_ABSOLUTE_DEVIATION: {
    impressions: 200,
    clicks: 15,
    conversions: 3,
    cost: 50        // account currency units
  },

  // Conversions report late. They are evaluated at (cutoff - this many
  // hours), against a baseline computed at the same shifted hour.
  CONVERSION_LAG_HOURS: 12,

  // If the lag-shifted conversion cutoff lands before this hour, skip
  // conversion checks for the run (window too small to be meaningful).
  CONVERSION_MIN_HOUR: 6,

  // Baseline validity floor. Historical days BEFORE this date are excluded.
  // Set this to the date of your last major restructure ('yyyy-MM-dd').
  // Empty string = no floor.
  BASELINE_VALID_FROM: '',

  // Minimum usable historical weekdays. Below this, the script logs
  // "insufficient baseline" and does not alert.
  MIN_BASELINE_SAMPLES: 4,

  // Earliest effective cutoff hour worth evaluating at all. Before this,
  // cumulative volume is too thin; the run just logs and exits.
  MIN_CUTOFF_HOUR: 5
};

// ===========================================================================
// No configuration below this line.
// ===========================================================================

var METRICS = ['impressions', 'clicks', 'conversions', 'cost'];

// Direction of badness: these alert when LOW...
var LOW_SIDE = { impressions: true, clicks: true, conversions: true };
// ...and cost alerts when HIGH.

function main() {
  var account = AdsApp.currentAccount();
  var tz = account.getTimeZone();
  var now = new Date();
  var today = formatDate(now, tz, 'yyyy-MM-dd');

  var ss = openSpreadsheet();
  ss.setSpreadsheetTimeZone(tz);
  var tabs = ensureTabs(ss);

  // Heartbeat: always write, success or fail paths both reach here first.
  tabs.status.getRange('B1').setValue(formatDate(now, tz, 'yyyy-MM-dd HH:mm:ss'));

  // ---- Pull today's hourly rows -------------------------------------------
  var todayRows = queryHourly(today, today, null);
  var nowHour = parseInt(formatDate(now, tz, 'H'), 10);

  // Zero-row / zero-impression day: pipeline or serving alert, not a
  // performance anomaly.
  var lastHour = lastHourWithImpressions(todayRows);
  if (lastHour < 0) {
    if (nowHour >= CONFIG.MIN_CUTOFF_HOUR) {
      flushEmail(tabs, today, account, [
          'No impressions reported for the entire day so far (' + nowHour +
          ':00 account time). This is almost never normal variance. Check: ' +
          'account/billing status, campaign statuses, or a Google reporting ' +
          'outage.']);
    }
    logRun(tabs, now, tz, 'ALL', 'no-data', '', '', '', 'zero rows today');
    return;
  }

  // Cap at the last COMPLETE hour: the current hour is almost always
  // partially reported, which would bias today low vs. the full-hour
  // historical baseline and produce false "too low" alerts.
  var effectiveCutoff = Math.min(lastHour, nowHour - 1);

  if (effectiveCutoff < CONFIG.MIN_CUTOFF_HOUR) {
    logRun(tabs, now, tz, 'ALL', 'skipped', '', '', '',
        'cutoff hour ' + effectiveCutoff + ' below MIN_CUTOFF_HOUR');
    return;
  }

  // ---- Pull historical same-weekday rows ----------------------------------
  var weekday = formatDate(now, tz, 'EEEE').toUpperCase();
  var histStart = dateStringDaysAgo(CONFIG.LOOKBACK_WEEKS * 7, now, tz);
  var histEnd = dateStringDaysAgo(1, now, tz);
  var histRows = queryHourly(histStart, histEnd, weekday);

  var excluded = readExcludedDates(tabs.excluded);

  // Cumulative-through-cutoff totals per historical date.
  var histDays = cumulateByDate(histRows, effectiveCutoff, excluded);
  var todayStats = cumulateSingleDay(todayRows, effectiveCutoff);

  // Conversion window: lag-shifted cutoff, its own baseline + today totals.
  var convCutoff = effectiveCutoff - CONFIG.CONVERSION_LAG_HOURS;
  var checkConversions = convCutoff >= CONFIG.CONVERSION_MIN_HOUR;
  var histDaysConv = checkConversions ?
      cumulateByDate(histRows, convCutoff, excluded) : null;
  var todayConv = checkConversions ?
      cumulateSingleDay(todayRows, convCutoff) : null;

  // ---- Evaluate ------------------------------------------------------------
  var alerts = [];
  var cutoffLabel = 'by ' + effectiveCutoff + ':59';

  for (var i = 0; i < METRICS.length; i++) {
    var metric = METRICS[i];

    var isConv = (metric === 'conversions');
    if (isConv && !checkConversions) {
      logRun(tabs, now, tz, metric, 'skipped', '', '', '',
          'conversion window before ' + CONFIG.CONVERSION_MIN_HOUR + ':00');
      continue;
    }

    var samples = pluck(isConv ? histDaysConv : histDays, metric);
    if (samples.length < CONFIG.MIN_BASELINE_SAMPLES) {
      logRun(tabs, now, tz, metric, 'insufficient-baseline',
          '', '', '', samples.length + ' samples after exclusions');
      continue;
    }

    var med = median(samples);
    var madVal = mad(samples, med);
    var band = CONFIG.MAD_MULTIPLIER * madVal;
    var actual = (isConv ? todayConv : todayStats)[metric];
    var minAbs = CONFIG.MIN_ABSOLUTE_DEVIATION[metric];

    var lowBound = med - band;
    var highBound = med + band;
    var fired = false;
    var direction = '';

    if (LOW_SIDE[metric]) {
      fired = (actual < lowBound) && ((med - actual) >= minAbs);
      direction = 'low';
    } else { // cost
      fired = (actual > highBound) && ((actual - med) >= minAbs);
      direction = 'high';
    }

    var rangeStr = fmt(lowBound) + ' – ' + fmt(highBound);

    if (fired) {
      var label = isConv ?
          'by ' + convCutoff + ':59 (lag-adjusted)' : cutoffLabel;
      var msg = capitalize(metric) + ' too ' + direction + ': ' +
          fmt(actual) + ' ' + label +
          '. Expected ~' + fmt(med) + ' (normal range ' + rangeStr + ').';
      alerts.push(msg);
      logRun(tabs, now, tz, metric, 'ALERT', fmt(med), fmt(actual),
          rangeStr, '');
    } else {
      logRun(tabs, now, tz, metric, 'ok', fmt(med), fmt(actual), rangeStr, '');
    }
  }

  // ---- Diagnostics + email --------------------------------------------------
  if (alerts.length > 0) {
    alerts.push('');
    alerts.push(buildDiagnostics(todayStats, histDays, todayConv,
        histDaysConv));
  }
  flushEmail(tabs, today, account, alerts);
}

// ============================ DATA LAYER ===================================

function queryHourly(startDate, endDate, weekdayOrNull) {
  var q =
      'SELECT segments.date, segments.hour, metrics.impressions, ' +
      'metrics.clicks, metrics.conversions, metrics.cost_micros ' +
      'FROM customer ' +
      'WHERE segments.date BETWEEN "' + startDate + '" AND "' + endDate + '"';
  if (weekdayOrNull) {
    q += ' AND segments.day_of_week = "' + weekdayOrNull + '"';
  }
  var rows = [];
  var it = AdsApp.search(q);
  while (it.hasNext()) {
    var r = it.next();
    rows.push({
      date: r.segments.date,
      hour: parseInt(r.segments.hour, 10),
      impressions: num(r.metrics.impressions),
      clicks: num(r.metrics.clicks),
      conversions: num(r.metrics.conversions),
      cost: num(r.metrics.costMicros) / 1000000
    });
  }
  return rows;
}

/** Latest hour today with nonzero impressions; -1 if none. */
function lastHourWithImpressions(rows) {
  var last = -1;
  for (var i = 0; i < rows.length; i++) {
    if (rows[i].impressions > 0 && rows[i].hour > last) last = rows[i].hour;
  }
  return last;
}

/** Per-date cumulative totals through cutoff hour (inclusive), as an array. */
function cumulateByDate(rows, cutoffHour, excludedDates) {
  var byDate = {};
  for (var i = 0; i < rows.length; i++) {
    var r = rows[i];
    if (r.hour > cutoffHour) continue;
    if (excludedDates[r.date]) continue;
    if (CONFIG.BASELINE_VALID_FROM && r.date < CONFIG.BASELINE_VALID_FROM) {
      continue;
    }
    if (!byDate[r.date]) {
      byDate[r.date] = { impressions: 0, clicks: 0, conversions: 0, cost: 0 };
    }
    var d = byDate[r.date];
    d.impressions += r.impressions;
    d.clicks += r.clicks;
    d.conversions += r.conversions;
    d.cost += r.cost;
  }
  var out = [];
  for (var k in byDate) out.push(byDate[k]);
  return out;
}

function cumulateSingleDay(rows, cutoffHour) {
  var t = { impressions: 0, clicks: 0, conversions: 0, cost: 0 };
  for (var i = 0; i < rows.length; i++) {
    var r = rows[i];
    if (r.hour > cutoffHour) continue;
    t.impressions += r.impressions;
    t.clicks += r.clicks;
    t.conversions += r.conversions;
    t.cost += r.cost;
  }
  return t;
}

// ============================ STATISTICS ===================================

function median(arr) {
  var a = arr.slice().sort(function(x, y) { return x - y; });
  var mid = Math.floor(a.length / 2);
  return a.length % 2 ? a[mid] : (a[mid - 1] + a[mid]) / 2;
}

/**
 * Median Absolute Deviation. If MAD is 0 (identical historical values, common
 * on very stable or very sparse metrics), fall back to 10% of the median so
 * the band never collapses to a single point; the absolute-deviation gate
 * remains the real guard in that case.
 *
 * Note: if the median itself is 0 (e.g. conversions on thin accounts), the
 * fallback is also 0 and the low-side statistical gate can never fire — but
 * neither can the absolute gate (med - actual >= minAbs is impossible from
 * a 0 median), so this is intentionally a no-alert zone: "below zero" is
 * not a meaningful anomaly.
 */
function mad(arr, med) {
  var devs = [];
  for (var i = 0; i < arr.length; i++) devs.push(Math.abs(arr[i] - med));
  var m = median(devs);
  return m > 0 ? m : med * 0.1;
}

function pluck(dayArray, metric) {
  var out = [];
  for (var i = 0; i < dayArray.length; i++) out.push(dayArray[i][metric]);
  return out;
}

// ============================ DIAGNOSTICS ==================================

/**
 * Ratio panel + likely-cause line, appended once per alert email.
 * Ratios never fire alerts themselves; they exist to explain the alert.
 *
 * CTR and CPC use the full-cutoff window. CVR and CPA use the lag-shifted
 * conversion window (todayConv/histDaysConv) so today's still-backfilling
 * conversions aren't compared against fully-reported history; when the
 * conversion window is too small (nulls), those lines print n/a.
 */
function buildDiagnostics(today, histDays, todayConv, histDaysConv) {
  var expected = {
    impressions: median(pluck(histDays, 'impressions')),
    clicks: median(pluck(histDays, 'clicks')),
    cost: median(pluck(histDays, 'cost'))
  };

  var lines = ['DIAGNOSTICS (today vs. typical, same cutoff):'];
  lines.push(ratioLine('CTR', safeDiv(today.clicks, today.impressions),
      safeDiv(expected.clicks, expected.impressions), true));
  lines.push(ratioLine('CPC', safeDiv(today.cost, today.clicks),
      safeDiv(expected.cost, expected.clicks), false));

  var convDiag = null;
  if (todayConv && histDaysConv && histDaysConv.length > 0) {
    convDiag = {
      clicks: median(pluck(histDaysConv, 'clicks')),
      conversions: median(pluck(histDaysConv, 'conversions')),
      cost: median(pluck(histDaysConv, 'cost'))
    };
    lines.push(ratioLine('CVR (lag-adj)',
        safeDiv(todayConv.conversions, todayConv.clicks),
        safeDiv(convDiag.conversions, convDiag.clicks), true));
    lines.push(ratioLine('CPA (lag-adj)',
        safeDiv(todayConv.cost, todayConv.conversions),
        safeDiv(convDiag.cost, convDiag.conversions), false));
  } else {
    lines.push('  CVR: n/a (conversion window too small this run)');
    lines.push('  CPA: n/a (conversion window too small this run)');
  }
  lines.push('');
  lines.push('Likely cause: ' +
      likelyCause(today, expected, todayConv, convDiag));
  return lines.join('\n');
}

function ratioLine(name, actual, expected, pctFormat) {
  var a = pctFormat ? pct(actual) : fmt(actual);
  var e = pctFormat ? pct(expected) : fmt(expected);
  var deltaStr = 'n/a';
  if (expected > 0) {
    var delta = Math.round(((actual - expected) / expected) * 100);
    deltaStr = (delta >= 0 ? '+' : '') + delta + '%';
  }
  return '  ' + name + ': ' + a + ' (typical ' + e + ', ' + deltaStr + ')';
}

/**
 * Simple decision table. Deviation = today vs expected, as a fraction.
 * Conversion-based branches only run when the lag-shifted window exists.
 */
function likelyCause(today, exp, todayConv, convExp) {
  var dImpr = frac(today.impressions, exp.impressions);
  var dClicks = frac(today.clicks, exp.clicks);
  var dCost = frac(today.cost, exp.cost);
  var hasConv = !!(todayConv && convExp);
  var dConv = hasConv ?
      frac(todayConv.conversions, convExp.conversions) : 0;
  var dConvClicks = hasConv ? frac(todayConv.clicks, convExp.clicks) : 0;
  var T = 0.25; // "meaningfully different" threshold for diagnosis only

  if (dImpr < -T && Math.abs(frac(safeDiv(today.clicks, today.impressions),
      safeDiv(exp.clicks, exp.impressions))) < T) {
    return 'Impressions are down while CTR is normal — a serving-side ' +
        'issue: rank/bids, budget limits, disapprovals, or a paused entity.';
  }
  if (Math.abs(dImpr) < T && dClicks < -T) {
    return 'Impressions are normal but clicks are down — a CTR problem: ' +
        'ad changes, SERP layout shift, or new competitor creative.';
  }
  if (hasConv && Math.abs(dConvClicks) < T && dConv < -T) {
    return 'Clicks are normal but conversions are down (lag-adjusted ' +
        'window) — the problem is after the click: landing page, form, or ' +
        'conversion tracking.';
  }
  if (dCost > T && Math.abs(dClicks) < T) {
    return 'Cost is up while clicks are flat — CPC inflation: auction ' +
        'pressure or a bid/bid-strategy change.';
  }
  if (dImpr < -T && dClicks < -T && (!hasConv || dConv < -T)) {
    return 'Everything is down proportionally — likely a serving or budget ' +
        'issue upstream, or a demand drop in the market.';
  }
  return 'Mixed signals — review the ratio panel above against recent ' +
      'account changes.';
}

// ========================= ALERTING ========================================

function flushEmail(tabs, today, account, alerts) {
  if (!alerts || alerts.length === 0 || !CONFIG.EMAIL_RECIPIENTS) return;

  var cid = account.getCustomerId();
  var body =
      'Anomaly report for Google Ads account ' + cid + ' — ' + today +
      '\n\n' + alerts.join('\n') + '\n\n' +
      'Dashboard: ' + CONFIG.SPREADSHEET_URL + '\n\n' +
      '—\nAccount Anomaly Detector v1\n' +
      'Nicolas Garfinkel (TryAdCortex.com)';

  MailApp.sendEmail(CONFIG.EMAIL_RECIPIENTS,
      '[Google Ads Alert] Account ' + cid + ' anomaly detected', body);
}

// ========================= SPREADSHEET SETUP ===============================

function openSpreadsheet() {
  if (CONFIG.SPREADSHEET_URL === 'YOUR_SPREADSHEET_URL') {
    throw new Error('Set CONFIG.SPREADSHEET_URL to a blank Google ' +
        'Spreadsheet you own. The script builds its own tabs.');
  }
  return SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
}

function ensureTabs(ss) {
  var status = ss.getSheetByName('Status');
  if (!status) {
    status = ss.insertSheet('Status');
    status.getRange('A1').setValue('Last run (heartbeat)');
  }
  var log = ss.getSheetByName('Log');
  if (!log) {
    log = ss.insertSheet('Log');
    log.appendRow(['timestamp', 'metric', 'result', 'expected(median)',
        'actual', 'normal range', 'note']);
  }
  var excluded = ss.getSheetByName('ExcludedDates');
  if (!excluded) {
    excluded = ss.insertSheet('ExcludedDates');
    excluded.getRange('A1').setValue(
        'Dates to exclude from baselines (yyyy-MM-dd), one per row below:');
  }
  return { status: status, log: log, excluded: excluded };
}

function readExcludedDates(sheet) {
  var out = {};
  var lastRow = sheet.getLastRow();
  if (lastRow < 2) return out;
  var vals = sheet.getRange(2, 1, lastRow - 1, 1).getValues();
  for (var i = 0; i < vals.length; i++) {
    var v = vals[i][0];
    if (!v) continue;
    if (v instanceof Date) {
      v = Utilities.formatDate(v, sheet.getParent().getSpreadsheetTimeZone(),
          'yyyy-MM-dd');
    }
    out[String(v)] = true;
  }
  return out;
}

/** Every evaluation is logged — this is the future threshold-tuning dataset. */
function logRun(tabs, now, tz, metric, result, expected, actual, range, note) {
  tabs.log.appendRow([formatDate(now, tz, 'yyyy-MM-dd HH:mm:ss'), metric,
      result, expected, actual, range, note]);
}

// ============================== UTILITIES ==================================

function num(v) { return parseFloat(v) || 0; }

function safeDiv(a, b) { return b > 0 ? a / b : 0; }

function frac(actual, expected) {
  return expected > 0 ? (actual - expected) / expected : 0;
}

function fmt(v) {
  return (Math.round(v * 100) / 100).toLocaleString();
}

function pct(v) { return (v * 100).toFixed(2) + '%'; }

function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }

function formatDate(date, tz, format) {
  return Utilities.formatDate(date, tz, format);
}

function dateStringDaysAgo(numDays, fromDate, tz) {
  // Anchor to ~noon account time before subtracting whole days: fixed
  // 24h-multiple subtraction from a near-midnight timestamp can cross a
  // DST transition and land on the wrong calendar date. From noon, a
  // +/-1h shift can never move the date boundary.
  var hour = parseInt(Utilities.formatDate(fromDate, tz, 'H'), 10);
  var noon = new Date(fromDate.getTime() + (12 - hour) * 3600 * 1000);
  var past = new Date(noon.getTime() - numDays * 24 * 3600 * 1000);
  return formatDate(past, tz, 'yyyy-MM-dd');
}

Why anomaly detection ultimately requires more sophistication

Even a statistically sound script is still working with one hand tied behind its back, because the hard part of anomaly detection isn't math — it's separating signal from noise. An account-level view can tell you that clicks are down; it can't tell you that one campaign cratered while the rest of the account absorbed the difference, that the "drop" is actually your own budget change from yesterday, or that three individually-unremarkable wobbles across campaigns add up to one very remarkable pattern. Every alert that turns out to be nothing trains you to ignore the next one — and the alert you ignore is always the one that mattered.

That's why we built anomaly detection directly into AdCortex, and why it goes several layers deeper than any script can. AdCortex watches your accounts across multiple detection layers — not just account totals, but the campaigns, spend patterns, and conversion behavior underneath them — and then, before anything reaches your inbox, an AI judgment layer reviews each candidate anomaly in context: how severe is it, is it explained by something we already know about, is it worth a human's attention right now? Only what survives that review lands in your daily digest, so what you receive reads less like a smoke detector and more like a sharp analyst who checked everything overnight and is only telling you what matters.

The result is the property that matters most in monitoring: when AdCortex flags something, it's worth your attention — and when it's quiet, you can actually trust the quiet. Tomorrow we'll publish a deep dive into exactly how AdCortex's anomaly detection works and why we think it's the best in the industry. In the meantime, grab the free script above — it's a genuine upgrade over the official one, and it'll be watching your account within the hour.

anomaly detection google ads scripts automation