Google Ads scriptWeekly or dailyRead-only

Status and budget change report

Who paused what, when, and from which tool: a summary of status and budget changes, auto-applied recommendations included.

Read-onlyEmailGoogle Sheet

The problem it solves

A campaign paused for ten days, a doubled budget, a Google recommendation applied automatically: the change history holds all of it, but nobody reads it. This script reads it for you and flags what was not your doing.

What the script does

  1. Reads the account’s change history for the last N days (30 at most, Google’s limit).
  2. Keeps status changes (campaigns, ad groups, ads, keywords), budget changes, creations and removals.
  3. Shows for each one the user and the originating tool: web UI, Editor, API, automated rule, auto-applied recommendation, script.
  4. Emails a summary, with automatic changes highlighted, and appends it to a Google Sheet if you provide one.

Installation

  1. In Google Ads: Tools and settings, Bulk actions, Scripts, then the "+" button.
  2. Paste the full code, replace the values of the CONFIG block at the top of the script.
  3. Click "Preview": the script runs without sending anything and shows its log. Authorise the requested access (Google Ads, email, Google Sheets depending on the script).
  4. Click "Save", then schedule the run with the clock icon, at the indicated frequency.

Settings to adapt

SettingRole
DAYS_BACKDays of history analysed, 1 to 29
EMAIL_RECIPIENTSRecipients, comma-separated
SPREADSHEET_URLOptional: Google Sheet where changes are archived
ONLY_IF_CHANGEStrue to send nothing when nothing changed
INCLUDE_OTHER_CHANGEStrue to also include edited bids, ads and targeting

The code

change-history-report.js
Download
/**
 * Status and budget change report (change history)
 * By Pierre Bordier — https://pierrebordier.com
 *
 * Who paused that campaign? When did this budget double? Did a recommendation apply
 * itself? This script reads the account's change history for the last N days, keeps status
 * changes (campaigns, ad groups, ads, keywords) and budget changes, shows who made them and
 * from which tool (web UI, Editor, API, automated rule, auto-applied recommendations,
 * script), then emails a summary and, if you want, appends everything to a Google Sheet.
 * Read-only: it never modifies the account.
 *
 * INSTALLATION
 * 1. Google Ads > Tools and settings > Bulk actions > Scripts > "+".
 * 2. Paste this whole file, adjust the CONFIG block below.
 * 3. "Preview" for a first test, then "Save".
 * 4. Schedule it (clock icon): weekly with DAYS_BACK = 7, or daily with DAYS_BACK = 1.
 *
 * KNOWN LIMITS
 * - The change history (change_event resource) only covers the last 30 days: DAYS_BACK is
 *   capped at 29.
 * - Google caps rows per query (10,000 max): on a very active account, run it more often
 *   rather than increasing DAYS_BACK.
 * - change_event fields occasionally change on Google's side; on a "field not found" error,
 *   check the schema at https://developers.google.com/google-ads/api/fields/latest/change_event
 */

const CONFIG = {
  // Number of days of history to analyse (1 to 29).
  DAYS_BACK: 7,

  // Recipients, comma-separated.
  EMAIL_RECIPIENTS: 'you@company.com',

  // Optional: URL of a Google Sheet where changes are appended (a "Changes" tab is created
  // or extended). Leave empty to skip.
  SPREADSHEET_URL: '',

  // true: only send the email when there is at least one change.
  ONLY_IF_CHANGES: true,

  // Also include changes that are neither status nor budget (bids, edited ads, targeting...).
  // false = status and budget only.
  INCLUDE_OTHER_CHANGES: false,

  // Maximum rows read (Google cap: 10000).
  MAX_ROWS: 5000,
};

// Readable labels for the tool behind the change.
const CLIENT_LABELS = {
  GOOGLE_ADS_WEB_CLIENT: 'Google Ads web UI',
  GOOGLE_ADS_AUTOMATED_RULE: 'Automated rule',
  GOOGLE_ADS_SCRIPTS: 'Script',
  GOOGLE_ADS_BULK_UPLOAD: 'Bulk upload',
  GOOGLE_ADS_API: 'API',
  GOOGLE_ADS_EDITOR: 'Google Ads Editor',
  GOOGLE_ADS_MOBILE_APP: 'Mobile app',
  GOOGLE_ADS_RECOMMENDATIONS: 'Recommendation applied',
  GOOGLE_ADS_RECOMMENDATIONS_SUBSCRIPTION: 'Recommendation auto-applied',
  SEARCH_ADS_360_SYNC: 'Search Ads 360',
  SEARCH_ADS_360_POST: 'Search Ads 360',
  INTERNAL_TOOL: 'Google internal tool',
  OTHER: 'Other',
};

function main() {
  const account = AdsApp.currentAccount();
  const tz = account.getTimeZone();
  const days = Math.min(Math.max(CONFIG.DAYS_BACK, 1), 29);
  const now = new Date();
  const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
  const fromStr = Utilities.formatDate(start, tz, 'yyyy-MM-dd HH:mm:ss');
  const toStr = Utilities.formatDate(now, tz, 'yyyy-MM-dd HH:mm:ss');

  const query = [
    'SELECT change_event.change_date_time, change_event.change_resource_type,',
    '  change_event.resource_change_operation, change_event.changed_fields,',
    '  change_event.user_email, change_event.client_type,',
    '  change_event.old_resource, change_event.new_resource,',
    '  campaign.name, ad_group.name',
    'FROM change_event',
    "WHERE change_event.change_date_time >= '" + fromStr + "'",
    "  AND change_event.change_date_time <= '" + toStr + "'",
    'ORDER BY change_event.change_date_time DESC',
    'LIMIT ' + CONFIG.MAX_ROWS,
  ].join('\n');

  const changes = [];
  const rows = AdsApp.search(query);
  while (rows.hasNext()) {
    const row = rows.next();
    const ev = row.changeEvent || {};
    const changed = String(ev.changedFields || '');
    const isStatus = /(^|,)\s*(status|primary_status)\b/i.test(changed) || /\bstatus\b/i.test(changed);
    const isBudget = /amount_micros|budget/i.test(changed) || ev.changeResourceType === 'CAMPAIGN_BUDGET';
    const isRemoval = ev.resourceChangeOperation === 'REMOVE' || ev.resourceChangeOperation === 'CREATE';
    if (!isStatus && !isBudget && !isRemoval && !CONFIG.INCLUDE_OTHER_CHANGES) continue;

    const detail = describe(ev);
    changes.push({
      when: String(ev.changeDateTime || ''),
      type: String(ev.changeResourceType || ''),
      operation: String(ev.resourceChangeOperation || ''),
      campaign: (row.campaign && row.campaign.name) || '',
      adGroup: (row.adGroup && row.adGroup.name) || '',
      who: String(ev.userEmail || ''),
      tool: CLIENT_LABELS[ev.clientType] || String(ev.clientType || ''),
      detail: detail,
      fields: changed,
    });
  }

  Logger.log(changes.length + ' change(s) kept over ' + days + ' day(s).');
  changes.slice(0, 20).forEach(function (c) {
    Logger.log(c.when + ' | ' + c.type + ' | ' + c.campaign + ' | ' + c.who + ' (' + c.tool + ') | ' + c.detail);
  });

  if (CONFIG.SPREADSHEET_URL) {
    try { appendToSheet(changes); } catch (e) { Logger.log('Google Sheet not updated: ' + e); }
  }

  if (changes.length === 0 && CONFIG.ONLY_IF_CHANGES) {
    Logger.log('No change, no email.');
    return;
  }
  const auto = changes.filter(function (c) { return /Recommendation|Automated/.test(c.tool); }).length;
  MailApp.sendEmail({
    to: CONFIG.EMAIL_RECIPIENTS,
    subject: '[Google Ads] ' + changes.length + ' status/budget change(s) in ' + days + ' d - ' + account.getName()
      + (auto ? ' (' + auto + ' automatic)' : ''),
    htmlBody: buildHtml(account, changes, days, auto),
  });
  Logger.log('Email sent to ' + CONFIG.EMAIL_RECIPIENTS);
}

// Describes the change from the before/after states, according to the resource type.
function describe(ev) {
  const oldR = ev.oldResource || {};
  const newR = ev.newResource || {};
  const pairs = [
    ['campaign', 'status'], ['adGroup', 'status'], ['adGroupAd', 'status'], ['adGroupCriterion', 'status'],
    ['campaignBudget', 'amountMicros'], ['campaign', 'name'], ['adGroupCriterion', 'cpcBidMicros'],
  ];
  const parts = [];
  pairs.forEach(function (p) {
    const before = oldR[p[0]] ? oldR[p[0]][p[1]] : undefined;
    const after = newR[p[0]] ? newR[p[0]][p[1]] : undefined;
    if (before === undefined && after === undefined) return;
    if (String(before) === String(after)) return;
    parts.push(p[0] + '.' + p[1] + ': ' + human(p[1], before) + ' -> ' + human(p[1], after));
  });
  if (parts.length) return parts.join('; ');
  return ev.resourceChangeOperation === 'REMOVE' ? 'removal' : ev.resourceChangeOperation === 'CREATE' ? 'creation' : 'fields: ' + String(ev.changedFields || '');
}

function human(field, value) {
  if (value === undefined || value === null) return '-';
  if (/Micros$/.test(field)) return (Number(value) / 1000000).toFixed(2);
  return String(value);
}

function appendToSheet(changes) {
  const ss = SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
  let sheet = ss.getSheetByName('Changes');
  if (!sheet) {
    sheet = ss.insertSheet('Changes');
    sheet.appendRow(['Date', 'Type', 'Operation', 'Campaign', 'Ad group', 'User', 'Tool', 'Detail', 'Fields']);
  }
  const values = changes.map(function (c) {
    return [c.when, c.type, c.operation, c.campaign, c.adGroup, c.who, c.tool, c.detail, c.fields];
  });
  if (values.length) {
    sheet.getRange(sheet.getLastRow() + 1, 1, values.length, values[0].length).setValues(values);
  }
}

function buildHtml(account, changes, days, auto) {
  let html = '<div style="font-family:Arial,sans-serif;font-size:13px">'
    + '<p>Account <b>' + escapeHtml(account.getName()) + '</b> (' + account.getCustomerId() + '): '
    + changes.length + ' status or budget change(s) over the last ' + days + ' days.</p>';
  if (auto) {
    html += '<p style="color:#b32d2e"><b>' + auto + ' change(s) were applied by an automated rule or an auto-applied recommendation.</b> '
      + 'Check that they match a decision of yours.</p>';
  }
  if (changes.length) {
    html += '<table border="1" cellpadding="5" style="border-collapse:collapse;font-size:12px">'
      + '<tr><th align="left">When</th><th align="left">Type</th><th align="left">Campaign</th><th align="left">Who</th><th align="left">Tool</th><th align="left">Detail</th></tr>';
    changes.slice(0, 200).forEach(function (c) {
      html += '<tr><td>' + escapeHtml(c.when) + '</td><td>' + escapeHtml(c.type) + '</td><td>' + escapeHtml(c.campaign)
        + (c.adGroup ? '<br><span style="color:#888">' + escapeHtml(c.adGroup) + '</span>' : '') + '</td><td>' + escapeHtml(c.who)
        + '</td><td>' + escapeHtml(c.tool) + '</td><td>' + escapeHtml(c.detail) + '</td></tr>';
    });
    html += '</table>';
    if (changes.length > 200) html += '<p>' + (changes.length - 200) + ' more changes in the Google Sheet.</p>';
  }
  html += '<p style="color:#888;font-size:12px">"Change report" script by Pierre Bordier, pierrebordier.com. Read-only.</p></div>';
  return html;
}

function escapeHtml(s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

Known limits

  • The history only goes back 30 days and Google caps each read at 10,000 rows: on a very active account, prefer a daily run.
  • Field names in the history occasionally change on Google’s side; the script reports the error instead of failing silently.