★ Community Edition ★Price: Free



The Scripts Desk

Change History Sentinel: know within hours who changed what in the account

A read-only script that queries the change_event log every few hours and emails you when someone makes a burst of edits or removes a campaign or budget.

The Change History panel in the UI is fine for a single, deliberate look. It is no use as an early warning system, because nobody opens it unless they already suspect something happened. This script watches instead: it queries the change log on a schedule, groups the edits by whoever made them, and emails you when one person makes an unusually large number of changes in a short window, or removes a campaign or a budget outright. It never touches the account itself. It reports edits, it does not undo them.

Before you start

  • You need access to the account and permission to authorise scripts.
  • Decide your bulk-edit threshold and your lookback window. A sensible starting point is flagging anyone who makes more than twenty changes inside a six-hour window.
  • The email alert is optional; with no address set, the script only writes to the log.

The hard constraints on change_event

The change_event resource is unusually strict, and the script is built around three limits that are easy to miss the first time:

  • It only ever returns the last 30 days. Nothing older is retrievable through the API at all.
  • The WHERE clause must filter on change_event.change_date_time, or the query is rejected outright.
  • The results must be ordered by change_event.change_date_time, and a query is capped at 10,000 rows. A very active account inside a wide date range can silently hit that cap; keep the queried window no wider than you need and let the script's own hour-level filter do the fine-grained work.

change_event.client_type tells you where an edit came from: GOOGLE_ADS_WEB_CLIENT, GOOGLE_ADS_EDITOR, GOOGLE_ADS_SCRIPTS, or GOOGLE_ADS_BULK_UPLOAD. change_event.resource_change_operation is one of CREATE, UPDATE, or REMOVE.

The script

JavaScript
/**
 * Change History Sentinel
 * The SEM Dispatch Vault, semdispatch.com/vault
 *
 * Reads the change_event log, groups edits by user within a rolling window,
 * and alerts on a burst of activity or a campaign/budget removal.
 * Read-only: this script never reverts or modifies any change.
 */

var CONFIG = {
  // How many hours back to group and count edits.
  LOOKBACK_HOURS: 6,

  // Alert when one user makes more edits than this inside the window.
  BULK_THRESHOLD: 20,

  // Optional. An email address for alerts, e.g. "you@example.com".
  // Leave empty to log only.
  EMAIL: "",

  // Optional. A Google Sheet URL for a running log of flagged activity.
  SHEET_URL: ""
};

function main() {
  var account = AdsApp.currentAccount();
  var tz = account.getTimeZone();
  var now = new Date();
  var cutoff = new Date(now.getTime() - CONFIG.LOOKBACK_HOURS * 60 * 60 * 1000);
  var cutoffStr = Utilities.formatDate(cutoff, tz, "yyyy-MM-dd HH:mm:ss");

  var query =
    "SELECT change_event.change_date_time, " +
    "change_event.user_email, " +
    "change_event.client_type, " +
    "change_event.change_resource_type, " +
    "change_event.resource_change_operation, " +
    "change_event.changed_fields, " +
    "change_event.campaign, " +
    "change_event.ad_group " +
    "FROM change_event " +
    "WHERE change_event.change_date_time DURING LAST_14_DAYS " +
    "ORDER BY change_event.change_date_time DESC " +
    "LIMIT 10000";

  var rows = AdsApp.report(query).rows();

  var byUser = {};
  var removalHits = [];
  var watchedResourceTypes = { CAMPAIGN: true, CAMPAIGN_BUDGET: true };

  while (rows.hasNext()) {
    var row = rows.next();
    var changedAt = row["change_event.change_date_time"];

    // Only the query's own 14-day pull is fixed; the alert window is this
    // in-script filter, so LOOKBACK_HOURS is the number that actually
    // controls sensitivity.
    if (changedAt < cutoffStr) continue;

    var user = row["change_event.user_email"] || "unknown";
    var resourceType = row["change_event.change_resource_type"];
    var operation = row["change_event.resource_change_operation"];

    if (!byUser[user]) {
      byUser[user] = { user: user, count: 0, removals: [] };
    }
    byUser[user].count += 1;

    if (operation === "REMOVE" && watchedResourceTypes[resourceType]) {
      var hit = {
        user: user,
        resourceType: resourceType,
        campaign: row["change_event.campaign"],
        changedAt: changedAt
      };
      byUser[user].removals.push(hit);
      removalHits.push(hit);
    }
  }

  var flaggedUsers = [];
  for (var u in byUser) {
    var record = byUser[u];
    if (record.count > CONFIG.BULK_THRESHOLD || record.removals.length > 0) {
      flaggedUsers.push(record);
    }
  }
  flaggedUsers.sort(function (a, b) { return b.count - a.count; });

  Logger.log("Change History Sentinel, " + account.getName());
  Logger.log("Window: last " + CONFIG.LOOKBACK_HOURS + " hours, cutoff " + cutoffStr);
  flaggedUsers.forEach(function (record) {
    Logger.log(
      record.user + ": " + record.count + " edits, " + record.removals.length +
      " campaign/budget removal(s)"
    );
  });

  if (CONFIG.SHEET_URL && CONFIG.SHEET_URL.indexOf("http") === 0) {
    var sheet = SpreadsheetApp.openByUrl(CONFIG.SHEET_URL).getSheets()[0];
    sheet.appendRow([Utilities.formatDate(now, tz, "yyyy-MM-dd HH:mm")]);
    flaggedUsers.forEach(function (record) {
      sheet.appendRow(["", record.user, record.count, record.removals.length]);
    });
  }

  if (CONFIG.EMAIL !== "" && flaggedUsers.length > 0) {
    var lines = [
      "Change History Sentinel, " + account.getName(),
      "Window: last " + CONFIG.LOOKBACK_HOURS + " hours",
      ""
    ];
    flaggedUsers.forEach(function (record) {
      lines.push(record.user + ": " + record.count + " edits");
      record.removals.forEach(function (hit) {
        lines.push("  removed " + hit.resourceType + " on campaign " + hit.campaign + " at " + hit.changedAt);
      });
    });
    MailApp.sendEmail(
      CONFIG.EMAIL,
      "[Change alert] " + account.getName() + ": " + flaggedUsers.length + " user(s) flagged",
      lines.join("\n")
    );
  }
}

Set it up

  1. In Google Ads, open Tools, then Bulk actions, then Scripts.
  2. Create a new script, delete the placeholder, and paste the code above.
  3. Set LOOKBACK_HOURS, BULK_THRESHOLD and EMAIL for the account. Add SHEET_URL if you want a running log.
  4. Authorise the script, then Preview it to confirm the log output looks right.
  5. Schedule it to run every few hours. A once-daily run finds a burst of edits after the fact; a few-hourly run finds it while it is still fresh enough to ask about.

How to read the alert

A high edit count from one person is not automatically bad; someone doing a planned account rebuild will legitimately generate dozens of changes. What the alert buys you is the question, asked promptly: was this planned. A removal on a campaign or a budget is treated separately and always surfaces, regardless of the count, because that is the one change type worth a same-day conversation even when it is a single edit.

One honest limitation: change_event caps at 10,000 rows and only looks back 30 days at the API level, so a very high-activity account querying a wide window can lose visibility into edits that scroll past the cap before the script's own hour filter gets to see them. Keep the queried range close to your alert window's needs, and treat this as a fast tripwire, not a permanent audit trail; for that, export change history on a longer cadence separately.

  • Change history
  • Alerts
  • Governance