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.
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
- Reads the account’s change history for the last N days (30 at most, Google’s limit).
- Keeps status changes (campaigns, ad groups, ads, keywords), budget changes, creations and removals.
- Shows for each one the user and the originating tool: web UI, Editor, API, automated rule, auto-applied recommendation, script.
- Emails a summary, with automatic changes highlighted, and appends it to a Google Sheet if you provide one.
Installation
- In Google Ads: Tools and settings, Bulk actions, Scripts, then the "+" button.
- Paste the full code, replace the values of the CONFIG block at the top of the script.
- 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).
- Click "Save", then schedule the run with the clock icon, at the indicated frequency.
Settings to adapt
| Setting | Role |
|---|---|
DAYS_BACK | Days of history analysed, 1 to 29 |
EMAIL_RECIPIENTS | Recipients, comma-separated |
SPREADSHEET_URL | Optional: Google Sheet where changes are archived |
ONLY_IF_CHANGES | true to send nothing when nothing changed |
INCLUDE_OTHER_CHANGES | true to also include edited bids, ads and targeting |
The code
/**
* 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, '&').replace(/</g, '<').replace(/>/g, '>');
}
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.