ROAS Keyword Labeler (MCC)
ROAS Keyword Labeler (MCC)
A Google Ads Script that runs at the manager (MCC) level and automatically labels keywords by how their ROAS (Conv. Value ÷ Cost) compares to the rest of their ad group:
Top ROAS— keywords that meaningfully beat their ad groupLow ROAS— keywords that meaningfully trail their ad group
Both labels are kept current on every run — keywords that cross back over the bar gain or lose the label automatically.
Built for agency use: one script, scheduled once, maintains both labels across every client account in parallel.
What it does
On each run, for every child account under the MCC:
- Creates the
Top ROASandLow ROASlabels in the account if they don't exist (entity labels are account-owned — same name and color everywhere, but each account has its own label object) - Pulls 90 days of ad group and keyword performance
- Classifies each keyword as a top performer, an underperformer, or neither (below)
- Applies each label to new qualifiers
- Removes each label from any keyword that no longer qualifies — including labels applied manually or by previous runs
Both labels are treated as fully script-owned. Don't hand-apply Top ROAS or Low ROAS for your own bookkeeping; use a differently named label for that.
Qualification tests
Both sides share the same sample floor (MIN_CONVERSIONS) and the same uncertainty margin (CONFIDENCE_Z), applied in opposite directions so each label stays conservative. The benchmark for both is the ROAS of the ad group excluding the keyword itself, so a dominant keyword isn't compared against an average that's mostly its own data. A keyword that is the only spender in its ad group is skipped either way — there are no peers to compare against.
The two labels are mutually exclusive — no keyword can earn both in the same run.
Top ROAS — must pass all five gates
| # | Gate | Why |
|---|---|---|
| 1 | ≥ MIN_CONVERSIONS conversions (default 30) | Enough sample to judge |
| 2 | Keyword's avg value per conversion > MIN_VALUE_PER_CONV (default $1) | A "top performer" with no real conversion value is a lead-gen artifact, not a win |
| 3 | Benchmark = ROAS of the ad group excluding the keyword itself | Fair comparison |
| 4 | Lower-bound ROAS: ROAS × (1 − Z / √conversions) | Discount observed ROAS downward — low-volume keywords must show a bigger observed edge |
| 5 | lowerROAS > benchmark × BENCHMARK_MULTIPLIER | The final comparison |
Low ROAS — must pass all five gates
| # | Gate | Why |
|---|---|---|
| 1 | ≥ MIN_CONVERSIONS conversions (default 30) | Enough sample to judge |
| 2 | Peers' avg value per conversion > MIN_VALUE_PER_CONV (default $1) | Gate is on the peers, not the keyword — a keyword burning spend at near-$0 value is exactly what we want to flag, but only inside an ad group that actually tracks revenue (excludes lead-gen accounts) |
| 3 | Benchmark = ROAS of the ad group excluding the keyword itself | Fair comparison |
| 4 | Upper-bound ROAS: ROAS × (1 + Z / √conversions) | Inflate observed ROAS upward — the benefit of the doubt, so low-volume keywords must trail by a bigger observed margin before we flag them |
| 5 | upperROAS < benchmark ÷ BENCHMARK_MULTIPLIER | The final comparison |
The uncertainty margin, briefly
Observed ROAS on 30 conversions is a noisy estimate; on 300 it's a solid one. The margin nudges the observed ROAS toward the cautious side in proportion to Z / √conversions — down for the top test, up for the low test — so a keyword only earns a label if it clears the bar even under the estimate least favorable to being labeled:
| Conversions | Margin at Z = 1.0 |
|---|---|
| 30 | ~18% |
| 55 | ~13% |
| 100 | ~10% |
| 250 | ~6% |
So a 30-conversion keyword needs to beat (or trail) its peers by roughly 22%+ to be labeled, while a 250-conversion keyword qualifies at ~7%+. That is the intended definition of meaningfully better — or worse.
Configuration
All knobs live in the CONFIG block at the top of the script and apply to every child account:
| Key | Default | Notes |
|---|---|---|
MANAGE_TOP | true | Set false to stop creating/applying/removing the Top ROAS label entirely |
TOP_LABEL_NAME | Top ROAS | Renaming later orphans old labels — remove them manually |
TOP_LABEL_COLOR | #22C55E | Tailwind green-500. Only used at label creation; recolor existing labels in the UI |
MANAGE_LOW | true | Set false to stop creating/applying/removing the Low ROAS label entirely |
LOW_LABEL_NAME | Low ROAS | Renaming later orphans old labels — remove them manually |
LOW_LABEL_COLOR | #EF4444 | Tailwind red-500. Only used at label creation; recolor existing labels in the UI |
LOOKBACK_DAYS | 90 | Rolling window, ends yesterday (no partial-day data) |
MIN_CONVERSIONS | 30 | Keyword-level floor (both sides) |
CONFIDENCE_Z | 1.0 | ~68% one-sided confidence. 1.28 ≈ 80% (stricter). 0 disables the margin |
BENCHMARK_MULTIPLIER | 1.0 | Extra margin over/under peers. 1.25 = must beat by 25% (top) or trail by 25% (low), after the uncertainty margin |
MIN_VALUE_PER_CONV | 1 | Revenue gate. Raise (e.g. 10) to exclude token-value conversions in e-comm accounts |
DEBUG | true | Logs the qualification math — see below |
Tuning cheat sheet
- Too few labels? Lower
CONFIDENCE_Zto0.84, or dropMIN_CONVERSIONSto 20. - Too many marginal labels? Raise
CONFIDENCE_Zto1.28, or setBENCHMARK_MULTIPLIERto1.1. - Want simple, no statistics?
CONFIDENCE_Z: 0+BENCHMARK_MULTIPLIER: 1.15= "beat/trail peers by 15%," flat. - Only want one label? Set
MANAGE_TOP: falseorMANAGE_LOW: false. The disabled side is left completely untouched — existing labels are neither created nor removed.
BENCHMARK_MULTIPLIER widens both bars symmetrically: the top bar moves up (× multiplier) and the low bar moves down (÷ multiplier), so a raise makes both labels harder to earn and leaves a wider "unremarkable" middle band untouched.
Installation
- Open the manager account → Tools → Bulk actions → Scripts (must be at the MCC level, not a client account)
- Create a new script, paste in
agencyAddTopROASLabel.js - Authorize when prompted (the script does nothing without this — it will appear to run and label zero keywords)
- Run Preview first and review the logs (see Preview limitations below)
- Run live once, verify labels in a couple of accounts
- Schedule daily (recommended — keeps labels fresh and removal churn small) or weekly
The script processes up to 50 child accounts per execution via executeInParallel; each account gets its own 30-minute execution limit.
Reading the logs
Each run ends with one line per account plus a totals footer:
Client A (123-456-7890) — ad groups: 41 | Top: qualifying 6, +2/-1 (4 kept) | Low: qualifying 3, +1/-0 (2 kept)
Client B (234-567-8901) — ad groups: 17 | Top: qualifying 0, +0/-0 (0 kept) | Low: qualifying 0, +0/-0 (0 kept)
----------------------------------------
Done. Accounts processed: 24, failed: 0. Top ROAS: +9/-3. Low ROAS: +4/-1.
qualifying 0 on both sides is normal for lead-gen accounts — the value-per-conversion gate excludes them by design.
Debug output
With DEBUG: true, the script logs the full math for every keyword in contention — i.e., raw ROAS above its peer average (TOP lines) or below it (LOW lines):
[Client A] TOP PASS "used tritoon utah" — conv: 112.0, ROAS: 6.90, peerROAS: 3.75, lowerROAS: 6.25 vs bar: 3.75
[Client A] TOP FAIL "pontoon dealer near me" — conv: 34.0, ROAS: 4.12, peerROAS: 3.80, lowerROAS: 3.42 vs bar: 3.80
[Client A] LOW PASS "cheap boat rental" — conv: 61.0, ROAS: 1.10, peerROAS: 3.80, upperROAS: 1.24 vs bar: 3.80
[Client A] LOW FAIL "boat trailer parts" — conv: 33.0, ROAS: 3.40, peerROAS: 3.80, upperROAS: 4.06 vs bar: 3.80
Because only keywords on the relevant side of the average are logged, every TOP FAIL means beat the ad group average, but not by enough to trust given the sample size, and every LOW FAIL means trailed the average, but not by enough to trust. Keywords that sit just under (or over) the bar run after run are worth a manual look — they're one streak away from being labeled.
Debug volume is small enough to leave on permanently.
Behavior notes & gotchas
- Preview mode can't show label mutations.
createLabelisn't persisted in Preview, so in accounts where a label doesn't exist yet, applying/removing is silently skipped (guarded — it won't error). Preview is still fully useful for the qualifying counts and debug math. The first live run creates labels and applies them. - First live run may remove labels in bulk if a previous version of the script (or manual labeling) applied them under looser criteria. That's the reconciliation working as intended.
- Paused keywords lose their labels on the next run — the sweep isn't status-filtered, but the qualifying query only considers enabled keywords in enabled ad groups/campaigns.
- Status filters: only
ENABLEDkeyword + ad group + campaign are eligible. Keywords in paused campaigns can never qualify. - Labels are per-account. Each client account owns its own
Top ROAS/Low ROASlabels with distinct label IDs. This only matters for cross-account reporting by label resource name. - GAQL quirk:
metrics.conversionsdoesn't support>=in a WHERE clause, so the conversion floor is enforced in code, not in the query. - Mixed-value ad groups: the peer benchmark uses the ad group's full totals. If an ad group mixes revenue-tracked keywords with valueless conversion actions, the valueless conversions drag the benchmark down slightly (making
Top ROASeasier to earn andLow ROASharder). Rare in practice; fixable if needed by computing benchmarks from value-carrying keywords only.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Runs clean, zero labels everywhere | Script not authorized; or all accounts fail the value gate (lead-gen, no conversion values) |
Low ROAS labels nothing but Top ROAS works | Peers-value gate: the ad groups may not track enough conversion value on the non-keyword traffic. Check MIN_VALUE_PER_CONV |
Expected one label named '...' but found 0 | You're running an old version without the Preview guards — update |
OPERATOR_FIELD_MISMATCH ... metrics.conversions | Old version with >= in the GAQL — update |
| Fewer labels than expected | Working as designed — check the debug FAIL lines and see the tuning cheat sheet |
| An account shows FAILED in the summary | Its error is logged on its own line; other accounts are unaffected |
Files
agencyAddTopROASLabel.js— the MCC script that fans out across all child accounts (use this for agency/manager setups)singleAddTopRoasLabel.js— single-account version (same logic; for an account outside an MCC). Don't run both against the same account — harmless but redundant.
The code
/**
* ROAS Keyword Labeler — MCC VERSION
* ----------------------------------
* Runs from a MANAGER (MCC) account and processes ALL child accounts in
* parallel. Each account gets its own labels (entity labels are account-owned;
* same name/color everywhere, but separate label objects).
*
* Applies TWO labels, based on how a keyword's ROAS (Conv. Value / Cost)
* compares to the REST of its ad group over the lookback window:
*
* • "Top ROAS" — keyword ROAS MEANINGFULLY BEATS the rest of its ad group
* • "Low ROAS" — keyword ROAS MEANINGFULLY TRAILS the rest of its ad group
*
* Both sides share the same sample floor and the same uncertainty margin —
* applied in OPPOSITE directions so each label stays conservative:
*
* Top qualification (all must pass):
* 1. Keyword has >= MIN_CONVERSIONS conversions
* 2. Keyword's avg value per conversion > MIN_VALUE_PER_CONV
* (filters lead-gen keywords with $0 or placeholder conversion values)
* 3. Benchmark = ROAS of the ad group EXCLUDING the keyword itself
* 4. LOWER-bound ROAS = ROAS * (1 - Z / sqrt(conversions))
* 5. lowerROAS > benchmark * BENCHMARK_MULTIPLIER
*
* Low qualification (all must pass):
* 1. Keyword has >= MIN_CONVERSIONS conversions
* 2. The ad group is revenue-tracked: the PEERS' avg value per conversion
* > MIN_VALUE_PER_CONV. The gate is on the peers, not the keyword, so a
* genuinely near-$0-value keyword still qualifies — but lead-gen ad
* groups (no real conversion values anywhere) are excluded.
* 3. Benchmark = ROAS of the ad group EXCLUDING the keyword itself
* 4. UPPER-bound ROAS = ROAS * (1 + Z / sqrt(conversions))
* 5. upperROAS < benchmark / BENCHMARK_MULTIPLIER
*
* Using the LOWER bound for "top" and the UPPER bound for "low" means a
* keyword must clear the bar even under the estimate least favorable to being
* labeled. Low-volume keywords need a bigger observed gap either way; the two
* labels are mutually exclusive by construction.
*
* Both labels are kept current per account: keywords that no longer qualify
* have the label removed on each run.
*
* Notes:
* - executeInParallel supports up to 50 accounts per run; each account
* gets its own 30-minute execution limit.
* - Schedule daily or weekly at the MCC level.
*/
// ---- Configuration (applies to every child account) ------------------------
const CONFIG = {
// Top ROAS label — keywords that meaningfully BEAT their ad group
MANAGE_TOP: true, // Set false to stop managing the Top ROAS label
TOP_LABEL_NAME: 'Top ROAS', // Label to apply/remove
TOP_LABEL_COLOR: '#22C55E', // Tailwind green-500 — used only at label creation
// Low ROAS label — keywords that meaningfully TRAIL their ad group
MANAGE_LOW: true, // Set false to stop managing the Low ROAS label
LOW_LABEL_NAME: 'Low ROAS', // Label to apply/remove
LOW_LABEL_COLOR: '#EF4444', // Tailwind red-500 — used only at label creation
// Shared knobs (apply to both sides)
LOOKBACK_DAYS: 90, // Rolling window for the ROAS comparison
MIN_CONVERSIONS: 30, // Keyword must have >= this many conversions
CONFIDENCE_Z: 1.0, // Uncertainty margin (~68% one-sided). 0 = off
BENCHMARK_MULTIPLIER: 1.0, // Extra margin vs peers (1.25 = beat/trail by 25%)
MIN_VALUE_PER_CONV: 1, // Revenue gate: skip when value/conv <= this
// (filters lead-gen: $0 or $1 placeholder values)
DEBUG: true, // Log qualification math — only for keywords
// on the relevant side of their peer average
};
// ---- MCC entry point -------------------------------------------------------
function main() {
// Select ALL child accounts under this manager (cap: 50 per execution)
const accountSelector = AdsManagerApp.accounts().withLimit(50);
// Fan out: processAccount() runs once per account, each in its own thread
// with its own 30-min limit. reportResults() aggregates when all finish.
accountSelector.executeInParallel('processAccount', 'reportResults');
}
/**
* Runs inside each child account's context — every AdsApp call below is
* automatically scoped to the account being processed.
* Returns a JSON summary string for the aggregator.
*/
function processAccount() {
const account = AdsApp.currentAccount();
// Ensure each managed label exists before we try to apply it
if (CONFIG.MANAGE_TOP) {
ensureLabelExists(
CONFIG.TOP_LABEL_NAME,
CONFIG.TOP_LABEL_COLOR,
'Keyword ROAS meaningfully beats rest of ad group (auto-managed by MCC script)'
);
}
if (CONFIG.MANAGE_LOW) {
ensureLabelExists(
CONFIG.LOW_LABEL_NAME,
CONFIG.LOW_LABEL_COLOR,
'Keyword ROAS meaningfully trails rest of ad group (auto-managed by MCC script)'
);
}
// Build the rolling date window (ends yesterday to avoid partial-day data)
const { startDate, endDate } = getDateRange(CONFIG.LOOKBACK_DAYS);
// Step 1: Ad group totals used to build per-keyword "rest of ad group" benchmarks
const adGroupTotals = buildAdGroupTotalsMap(startDate, endDate);
// Step 2: Classify keywords into top / low sets in a single pass (see header)
const { top, low } = classifyKeywords(startDate, endDate, adGroupTotals);
// Step 3: Sync each label — remove it from stale keywords, add it to new
// qualifiers. A disabled side is left completely untouched.
const topResult = CONFIG.MANAGE_TOP
? syncLabel(top, CONFIG.TOP_LABEL_NAME)
: { added: 0, removed: 0, unchanged: 0 };
const lowResult = CONFIG.MANAGE_LOW
? syncLabel(low, CONFIG.LOW_LABEL_NAME)
: { added: 0, removed: 0, unchanged: 0 };
// Return a per-account summary for reportResults()
return JSON.stringify({
accountName: account.getName(),
customerId: account.getCustomerId(),
adGroups: Object.keys(adGroupTotals).length,
topQualifying: top.size,
lowQualifying: low.size,
topAdded: topResult.added,
topRemoved: topResult.removed,
topUnchanged: topResult.unchanged,
lowAdded: lowResult.added,
lowRemoved: lowResult.removed,
lowUnchanged: lowResult.unchanged,
});
}
/**
* Removes a label from keywords that no longer qualify and applies it to new
* qualifiers. Returns { added, removed, unchanged } for the summary.
*/
function syncLabel(qualifying, labelName) {
const sweep = removeStaleLabels(qualifying, labelName);
const toAdd = [...qualifying].filter((key) => !sweep.alreadyLabeled.has(key));
applyLabel(toAdd, labelName);
return {
added: toAdd.length,
removed: sweep.removed,
unchanged: sweep.alreadyLabeled.size,
};
}
/**
* Aggregator — runs once at the MCC level after all accounts finish.
* Logs a per-account summary plus any per-account errors.
*/
function reportResults(results) {
let topAdded = 0;
let topRemoved = 0;
let lowAdded = 0;
let lowRemoved = 0;
let failures = 0;
for (const result of results) {
// Surface per-account failures without killing the whole run
if (result.getStatus() !== 'OK') {
failures++;
Logger.log(`FAILED ${result.getCustomerId()}: ${result.getError()}`);
continue;
}
// Guard: a crashed/odd thread can report OK but return nothing parseable
const raw = result.getReturnValue();
let s;
try {
s = JSON.parse(raw);
} catch (e) {
failures++;
Logger.log(`FAILED ${result.getCustomerId()}: empty/invalid summary returned.`);
continue;
}
topAdded += s.topAdded;
topRemoved += s.topRemoved;
lowAdded += s.lowAdded;
lowRemoved += s.lowRemoved;
Logger.log(
`${s.accountName} (${s.customerId}) — ad groups: ${s.adGroups} | ` +
`Top: qualifying ${s.topQualifying}, +${s.topAdded}/-${s.topRemoved} (${s.topUnchanged} kept) | ` +
`Low: qualifying ${s.lowQualifying}, +${s.lowAdded}/-${s.lowRemoved} (${s.lowUnchanged} kept)`
);
}
Logger.log('----------------------------------------');
Logger.log(
`Done. Accounts processed: ${results.length - failures}, failed: ${failures}. ` +
`Top ROAS: +${topAdded}/-${topRemoved}. Low ROAS: +${lowAdded}/-${lowRemoved}.`
);
}
// ---- Label management ------------------------------------------------------
/** Creates a label in the CURRENT child account if it doesn't exist. */
function ensureLabelExists(labelName, labelColor, description) {
const existing = AdsApp.labels()
.withCondition(`label.name = '${labelName}'`)
.get();
if (!existing.hasNext()) {
AdsApp.createLabel(labelName, description, labelColor);
}
}
// ---- Date helpers ----------------------------------------------------------
/** Returns { startDate, endDate } as yyyy-MM-dd strings in account timezone. */
function getDateRange(lookbackDays) {
const tz = AdsApp.currentAccount().getTimeZone();
const msPerDay = 24 * 60 * 60 * 1000;
const end = new Date(Date.now() - 1 * msPerDay); // yesterday
const start = new Date(Date.now() - lookbackDays * msPerDay); // N days back
return {
startDate: Utilities.formatDate(start, tz, 'yyyy-MM-dd'),
endDate: Utilities.formatDate(end, tz, 'yyyy-MM-dd'),
};
}
// ---- Data collection -------------------------------------------------------
/**
* Queries ad group totals and returns a map of
* adGroupId -> { cost, value, conversions } so each keyword can be benchmarked
* against the ad group EXCLUDING its own contribution.
*/
function buildAdGroupTotalsMap(startDate, endDate) {
const totals = {};
const query = `
SELECT ad_group.id, metrics.cost_micros, metrics.conversions_value,
metrics.conversions
FROM ad_group
WHERE segments.date BETWEEN '${startDate}' AND '${endDate}'
AND metrics.cost_micros > 0
AND ad_group.status = 'ENABLED'
AND campaign.status = 'ENABLED'`;
const rows = AdsApp.search(query);
while (rows.hasNext()) {
const row = rows.next();
totals[String(row.adGroup.id)] = {
cost: Number(row.metrics.costMicros) / 1e6, // micros -> currency
value: Number(row.metrics.conversionsValue),
conversions: Number(row.metrics.conversions),
};
}
return totals;
}
/**
* Queries keyword-level stats and classifies each keyword against the rest of
* its ad group. Returns { top, low } — two Sets of keys ("adGroupId,criterionId")
* for the keywords that pass the top / low qualification tests (see header).
*/
function classifyKeywords(startDate, endDate, adGroupTotals) {
const top = new Set();
const low = new Set();
const query = `
SELECT ad_group.id, ad_group_criterion.criterion_id,
ad_group_criterion.keyword.text,
metrics.conversions, metrics.conversions_value, metrics.cost_micros
FROM keyword_view
WHERE segments.date BETWEEN '${startDate}' AND '${endDate}'
AND metrics.cost_micros > 0
AND ad_group_criterion.status = 'ENABLED'
AND ad_group.status = 'ENABLED'
AND campaign.status = 'ENABLED'`;
const rows = AdsApp.search(query);
while (rows.hasNext()) {
const row = rows.next();
const adGroupId = String(row.adGroup.id);
const agTotals = adGroupTotals[adGroupId];
if (agTotals === undefined) continue; // no ad group data -> skip
// GAQL doesn't allow >= on metrics.conversions, so threshold is applied here
const conversions = Number(row.metrics.conversions);
if (conversions < CONFIG.MIN_CONVERSIONS) continue;
const kwText = row.adGroupCriterion.keyword.text;
const kwCost = Number(row.metrics.costMicros) / 1e6;
const kwValue = Number(row.metrics.conversionsValue);
const kwRoas = kwValue / kwCost;
const key = `${adGroupId},${row.adGroupCriterion.criterionId}`;
// Benchmark = the ad group EXCLUDING this keyword, so a dominant keyword
// isn't compared against (mostly) itself. If the keyword is the only
// spender in the ad group there are no peers to compare against -> skip.
const peerCost = agTotals.cost - kwCost;
const peerValue = agTotals.value - kwValue;
const peerConversions = agTotals.conversions - conversions;
if (peerCost <= 0) continue;
const peerRoas = Math.max(peerValue, 0) / peerCost;
// ---- Top side: keyword conservatively BEATS its peers ----
// Gate on the keyword's OWN value per conversion — a top performer with no
// real conversion value is a lead-gen artifact, not a win.
if (CONFIG.MANAGE_TOP && kwValue / conversions > CONFIG.MIN_VALUE_PER_CONV) {
// Lower-bound ROAS: shrink observed ROAS toward zero by Z / sqrt(conv).
// Fewer conversions -> bigger discount -> bigger observed edge required.
const lowerRoas =
kwRoas * Math.max(1 - CONFIG.CONFIDENCE_Z / Math.sqrt(conversions), 0);
const bar = peerRoas * CONFIG.BENCHMARK_MULTIPLIER;
const passed = lowerRoas > bar;
// Math trail — only for keywords in contention (raw ROAS above peers)
if (kwRoas > peerRoas) {
debugLog(
`TOP ${passed ? 'PASS' : 'FAIL'} "${kwText}" — conv: ${conversions.toFixed(1)}, ` +
`ROAS: ${kwRoas.toFixed(2)}, peerROAS: ${peerRoas.toFixed(2)}, ` +
`lowerROAS: ${lowerRoas.toFixed(2)} vs bar: ${bar.toFixed(2)}`
);
}
if (passed) top.add(key);
}
// ---- Low side: keyword conservatively TRAILS its peers ----
// Gate on the PEERS' value per conversion, not the keyword's own. A keyword
// burning spend at near-$0 value is exactly what we want to flag, but only
// inside an ad group that actually tracks revenue (excludes lead-gen).
if (
CONFIG.MANAGE_LOW &&
peerConversions > 0 &&
peerValue / peerConversions > CONFIG.MIN_VALUE_PER_CONV
) {
// Upper-bound ROAS: inflate observed ROAS by Z / sqrt(conv) — the benefit
// of the doubt. Fewer conversions -> bigger inflation -> a keyword must
// trail by a wider observed margin before we flag it as underperforming.
const upperRoas = kwRoas * (1 + CONFIG.CONFIDENCE_Z / Math.sqrt(conversions));
const bar =
CONFIG.BENCHMARK_MULTIPLIER > 0
? peerRoas / CONFIG.BENCHMARK_MULTIPLIER
: peerRoas;
const passed = upperRoas < bar;
// Math trail — only for keywords in contention (raw ROAS below peers)
if (kwRoas < peerRoas) {
debugLog(
`LOW ${passed ? 'PASS' : 'FAIL'} "${kwText}" — conv: ${conversions.toFixed(1)}, ` +
`ROAS: ${kwRoas.toFixed(2)}, peerROAS: ${peerRoas.toFixed(2)}, ` +
`upperROAS: ${upperRoas.toFixed(2)} vs bar: ${bar.toFixed(2)}`
);
}
if (passed) low.add(key);
}
}
return { top, low };
}
/** Logs only when CONFIG.DEBUG is on. */
function debugLog(message) {
if (CONFIG.DEBUG) {
Logger.log(`[${AdsApp.currentAccount().getName()}] ${message}`);
}
}
// ---- Label application / removal -------------------------------------------
/**
* Iterates keywords that currently carry `labelName` in this account. Removes
* the label from any keyword not in the qualifying set. Returns:
* { alreadyLabeled: Set of qualified+labeled keys, removed: count }
*/
function removeStaleLabels(qualifying, labelName) {
const alreadyLabeled = new Set();
let removed = 0;
// The LabelNames selector THROWS if the label doesn't exist in this account
// (e.g. first run, or Preview mode where createLabel isn't persisted).
// No label = nothing can be labeled yet, so skip the sweep entirely.
const labelCheck = AdsApp.labels()
.withCondition(`label.name = '${labelName}'`)
.get();
if (!labelCheck.hasNext()) {
return { alreadyLabeled, removed };
}
const labeledKeywords = AdsApp.keywords()
.withCondition(`LabelNames CONTAINS_ANY ['${labelName}']`)
.get();
while (labeledKeywords.hasNext()) {
const keyword = labeledKeywords.next();
const key = `${keyword.getAdGroup().getId()},${keyword.getId()}`;
if (qualifying.has(key)) {
alreadyLabeled.add(key); // still qualifies
} else {
keyword.removeLabel(labelName); // no longer qualifies
removed++;
}
}
return { alreadyLabeled, removed };
}
/** Applies `labelName` to keywords identified by "adGroupId,criterionId" keys. */
function applyLabel(keys, labelName) {
if (keys.length === 0) return;
// Same guard as the sweep: in Preview mode the label was never persisted,
// so applyLabel-by-name would throw. Skip (Preview still logs the counts).
const labelCheck = AdsApp.labels()
.withCondition(`label.name = '${labelName}'`)
.get();
if (!labelCheck.hasNext()) return;
// withIds() accepts up to 10,000 [adGroupId, criterionId] pairs per selector
const CHUNK_SIZE = 10000;
for (let i = 0; i < keys.length; i += CHUNK_SIZE) {
const idPairs = keys
.slice(i, i + CHUNK_SIZE)
.map((key) => key.split(',').map(Number)); // -> [adGroupId, critId]
const keywords = AdsApp.keywords().withIds(idPairs).get();
while (keywords.hasNext()) {
keywords.next().applyLabel(labelName);
}
}
}
/**
* ROAS Keyword Labeler — SINGLE-ACCOUNT VERSION
* ---------------------------------------------
* Runs inside ONE Google Ads account (not a manager account). For the MCC
* fan-out version that processes every child account in parallel, use
* agencyAddTopROASLabel.js instead. Don't run both against the same account —
* harmless but redundant.
*
* Applies TWO labels, based on how a keyword's ROAS (Conv. Value / Cost)
* compares to the REST of its ad group over the lookback window:
*
* • "Top ROAS" — keyword ROAS MEANINGFULLY BEATS the rest of its ad group
* • "Low ROAS" — keyword ROAS MEANINGFULLY TRAILS the rest of its ad group
*
* Both sides share the same sample floor and the same uncertainty margin —
* applied in OPPOSITE directions so each label stays conservative:
*
* Top qualification (all must pass):
* 1. Keyword has >= MIN_CONVERSIONS conversions
* 2. Keyword's avg value per conversion > MIN_VALUE_PER_CONV
* (filters lead-gen keywords with $0 or placeholder conversion values)
* 3. Benchmark = ROAS of the ad group EXCLUDING the keyword itself
* 4. LOWER-bound ROAS = ROAS * (1 - Z / sqrt(conversions))
* 5. lowerROAS > benchmark * BENCHMARK_MULTIPLIER
*
* Low qualification (all must pass):
* 1. Keyword has >= MIN_CONVERSIONS conversions
* 2. The ad group is revenue-tracked: the PEERS' avg value per conversion
* > MIN_VALUE_PER_CONV. The gate is on the peers, not the keyword, so a
* genuinely near-$0-value keyword still qualifies — but lead-gen ad
* groups (no real conversion values anywhere) are excluded.
* 3. Benchmark = ROAS of the ad group EXCLUDING the keyword itself
* 4. UPPER-bound ROAS = ROAS * (1 + Z / sqrt(conversions))
* 5. upperROAS < benchmark / BENCHMARK_MULTIPLIER
*
* Using the LOWER bound for "top" and the UPPER bound for "low" means a
* keyword must clear the bar even under the estimate least favorable to being
* labeled. Low-volume keywords need a bigger observed gap either way; the two
* labels are mutually exclusive by construction.
*
* Both labels are kept current: keywords that no longer qualify have the
* label removed on each run.
*
* Notes:
* - Runs in a single account; schedule daily or weekly on that account.
*/
// ---- Configuration ---------------------------------------------------------
const CONFIG = {
// Top ROAS label — keywords that meaningfully BEAT their ad group
MANAGE_TOP: true, // Set false to stop managing the Top ROAS label
TOP_LABEL_NAME: 'Top ROAS', // Label to apply/remove
TOP_LABEL_COLOR: '#22C55E', // Tailwind green-500 — used only at label creation
// Low ROAS label — keywords that meaningfully TRAIL their ad group
MANAGE_LOW: true, // Set false to stop managing the Low ROAS label
LOW_LABEL_NAME: 'Low ROAS', // Label to apply/remove
LOW_LABEL_COLOR: '#EF4444', // Tailwind red-500 — used only at label creation
// Shared knobs (apply to both sides)
LOOKBACK_DAYS: 90, // Rolling window for the ROAS comparison
MIN_CONVERSIONS: 30, // Keyword must have >= this many conversions
CONFIDENCE_Z: 1.0, // Uncertainty margin (~68% one-sided). 0 = off
BENCHMARK_MULTIPLIER: 1.0, // Extra margin vs peers (1.25 = beat/trail by 25%)
MIN_VALUE_PER_CONV: 1, // Revenue gate: skip when value/conv <= this
// (filters lead-gen: $0 or $1 placeholder values)
DEBUG: true, // Log qualification math — only for keywords
// on the relevant side of their peer average
};
// ---- Single-account entry point --------------------------------------------
function main() {
const account = AdsApp.currentAccount();
// Ensure each managed label exists before we try to apply it
if (CONFIG.MANAGE_TOP) {
ensureLabelExists(
CONFIG.TOP_LABEL_NAME,
CONFIG.TOP_LABEL_COLOR,
'Keyword ROAS meaningfully beats rest of ad group (auto-managed by script)'
);
}
if (CONFIG.MANAGE_LOW) {
ensureLabelExists(
CONFIG.LOW_LABEL_NAME,
CONFIG.LOW_LABEL_COLOR,
'Keyword ROAS meaningfully trails rest of ad group (auto-managed by script)'
);
}
// Build the rolling date window (ends yesterday to avoid partial-day data)
const { startDate, endDate } = getDateRange(CONFIG.LOOKBACK_DAYS);
// Step 1: Ad group totals used to build per-keyword "rest of ad group" benchmarks
const adGroupTotals = buildAdGroupTotalsMap(startDate, endDate);
// Step 2: Classify keywords into top / low sets in a single pass (see header)
const { top, low } = classifyKeywords(startDate, endDate, adGroupTotals);
// Step 3: Sync each label — remove it from stale keywords, add it to new
// qualifiers. A disabled side is left completely untouched.
const topResult = CONFIG.MANAGE_TOP
? syncLabel(top, CONFIG.TOP_LABEL_NAME)
: { added: 0, removed: 0, unchanged: 0 };
const lowResult = CONFIG.MANAGE_LOW
? syncLabel(low, CONFIG.LOW_LABEL_NAME)
: { added: 0, removed: 0, unchanged: 0 };
// Log a single-account summary
Logger.log(
`${account.getName()} (${account.getCustomerId()}) — ad groups: ` +
`${Object.keys(adGroupTotals).length} | ` +
`Top: qualifying ${top.size}, +${topResult.added}/-${topResult.removed} (${topResult.unchanged} kept) | ` +
`Low: qualifying ${low.size}, +${lowResult.added}/-${lowResult.removed} (${lowResult.unchanged} kept)`
);
}
/**
* Removes a label from keywords that no longer qualify and applies it to new
* qualifiers. Returns { added, removed, unchanged } for the summary.
*/
function syncLabel(qualifying, labelName) {
const sweep = removeStaleLabels(qualifying, labelName);
const toAdd = [...qualifying].filter((key) => !sweep.alreadyLabeled.has(key));
applyLabel(toAdd, labelName);
return {
added: toAdd.length,
removed: sweep.removed,
unchanged: sweep.alreadyLabeled.size,
};
}
// ---- Label management ------------------------------------------------------
/** Creates a label in the CURRENT child account if it doesn't exist. */
function ensureLabelExists(labelName, labelColor, description) {
const existing = AdsApp.labels()
.withCondition(`label.name = '${labelName}'`)
.get();
if (!existing.hasNext()) {
AdsApp.createLabel(labelName, description, labelColor);
}
}
// ---- Date helpers ----------------------------------------------------------
/** Returns { startDate, endDate } as yyyy-MM-dd strings in account timezone. */
function getDateRange(lookbackDays) {
const tz = AdsApp.currentAccount().getTimeZone();
const msPerDay = 24 * 60 * 60 * 1000;
const end = new Date(Date.now() - 1 * msPerDay); // yesterday
const start = new Date(Date.now() - lookbackDays * msPerDay); // N days back
return {
startDate: Utilities.formatDate(start, tz, 'yyyy-MM-dd'),
endDate: Utilities.formatDate(end, tz, 'yyyy-MM-dd'),
};
}
// ---- Data collection -------------------------------------------------------
/**
* Queries ad group totals and returns a map of
* adGroupId -> { cost, value, conversions } so each keyword can be benchmarked
* against the ad group EXCLUDING its own contribution.
*/
function buildAdGroupTotalsMap(startDate, endDate) {
const totals = {};
const query = `
SELECT ad_group.id, metrics.cost_micros, metrics.conversions_value,
metrics.conversions
FROM ad_group
WHERE segments.date BETWEEN '${startDate}' AND '${endDate}'
AND metrics.cost_micros > 0
AND ad_group.status = 'ENABLED'
AND campaign.status = 'ENABLED'`;
const rows = AdsApp.search(query);
while (rows.hasNext()) {
const row = rows.next();
totals[String(row.adGroup.id)] = {
cost: Number(row.metrics.costMicros) / 1e6, // micros -> currency
value: Number(row.metrics.conversionsValue),
conversions: Number(row.metrics.conversions),
};
}
return totals;
}
/**
* Queries keyword-level stats and classifies each keyword against the rest of
* its ad group. Returns { top, low } — two Sets of keys ("adGroupId,criterionId")
* for the keywords that pass the top / low qualification tests (see header).
*/
function classifyKeywords(startDate, endDate, adGroupTotals) {
const top = new Set();
const low = new Set();
const query = `
SELECT ad_group.id, ad_group_criterion.criterion_id,
ad_group_criterion.keyword.text,
metrics.conversions, metrics.conversions_value, metrics.cost_micros
FROM keyword_view
WHERE segments.date BETWEEN '${startDate}' AND '${endDate}'
AND metrics.cost_micros > 0
AND ad_group_criterion.status = 'ENABLED'
AND ad_group.status = 'ENABLED'
AND campaign.status = 'ENABLED'`;
const rows = AdsApp.search(query);
while (rows.hasNext()) {
const row = rows.next();
const adGroupId = String(row.adGroup.id);
const agTotals = adGroupTotals[adGroupId];
if (agTotals === undefined) continue; // no ad group data -> skip
// GAQL doesn't allow >= on metrics.conversions, so threshold is applied here
const conversions = Number(row.metrics.conversions);
if (conversions < CONFIG.MIN_CONVERSIONS) continue;
const kwText = row.adGroupCriterion.keyword.text;
const kwCost = Number(row.metrics.costMicros) / 1e6;
const kwValue = Number(row.metrics.conversionsValue);
const kwRoas = kwValue / kwCost;
const key = `${adGroupId},${row.adGroupCriterion.criterionId}`;
// Benchmark = the ad group EXCLUDING this keyword, so a dominant keyword
// isn't compared against (mostly) itself. If the keyword is the only
// spender in the ad group there are no peers to compare against -> skip.
const peerCost = agTotals.cost - kwCost;
const peerValue = agTotals.value - kwValue;
const peerConversions = agTotals.conversions - conversions;
if (peerCost <= 0) continue;
const peerRoas = Math.max(peerValue, 0) / peerCost;
// ---- Top side: keyword conservatively BEATS its peers ----
// Gate on the keyword's OWN value per conversion — a top performer with no
// real conversion value is a lead-gen artifact, not a win.
if (CONFIG.MANAGE_TOP && kwValue / conversions > CONFIG.MIN_VALUE_PER_CONV) {
// Lower-bound ROAS: shrink observed ROAS toward zero by Z / sqrt(conv).
// Fewer conversions -> bigger discount -> bigger observed edge required.
const lowerRoas =
kwRoas * Math.max(1 - CONFIG.CONFIDENCE_Z / Math.sqrt(conversions), 0);
const bar = peerRoas * CONFIG.BENCHMARK_MULTIPLIER;
const passed = lowerRoas > bar;
// Math trail — only for keywords in contention (raw ROAS above peers)
if (kwRoas > peerRoas) {
debugLog(
`TOP ${passed ? 'PASS' : 'FAIL'} "${kwText}" — conv: ${conversions.toFixed(1)}, ` +
`ROAS: ${kwRoas.toFixed(2)}, peerROAS: ${peerRoas.toFixed(2)}, ` +
`lowerROAS: ${lowerRoas.toFixed(2)} vs bar: ${bar.toFixed(2)}`
);
}
if (passed) top.add(key);
}
// ---- Low side: keyword conservatively TRAILS its peers ----
// Gate on the PEERS' value per conversion, not the keyword's own. A keyword
// burning spend at near-$0 value is exactly what we want to flag, but only
// inside an ad group that actually tracks revenue (excludes lead-gen).
if (
CONFIG.MANAGE_LOW &&
peerConversions > 0 &&
peerValue / peerConversions > CONFIG.MIN_VALUE_PER_CONV
) {
// Upper-bound ROAS: inflate observed ROAS by Z / sqrt(conv) — the benefit
// of the doubt. Fewer conversions -> bigger inflation -> a keyword must
// trail by a wider observed margin before we flag it as underperforming.
const upperRoas = kwRoas * (1 + CONFIG.CONFIDENCE_Z / Math.sqrt(conversions));
const bar =
CONFIG.BENCHMARK_MULTIPLIER > 0
? peerRoas / CONFIG.BENCHMARK_MULTIPLIER
: peerRoas;
const passed = upperRoas < bar;
// Math trail — only for keywords in contention (raw ROAS below peers)
if (kwRoas < peerRoas) {
debugLog(
`LOW ${passed ? 'PASS' : 'FAIL'} "${kwText}" — conv: ${conversions.toFixed(1)}, ` +
`ROAS: ${kwRoas.toFixed(2)}, peerROAS: ${peerRoas.toFixed(2)}, ` +
`upperROAS: ${upperRoas.toFixed(2)} vs bar: ${bar.toFixed(2)}`
);
}
if (passed) low.add(key);
}
}
return { top, low };
}
/** Logs only when CONFIG.DEBUG is on. */
function debugLog(message) {
if (CONFIG.DEBUG) {
Logger.log(`[${AdsApp.currentAccount().getName()}] ${message}`);
}
}
// ---- Label application / removal -------------------------------------------
/**
* Iterates keywords that currently carry `labelName` in this account. Removes
* the label from any keyword not in the qualifying set. Returns:
* { alreadyLabeled: Set of qualified+labeled keys, removed: count }
*/
function removeStaleLabels(qualifying, labelName) {
const alreadyLabeled = new Set();
let removed = 0;
// The LabelNames selector THROWS if the label doesn't exist in this account
// (e.g. first run, or Preview mode where createLabel isn't persisted).
// No label = nothing can be labeled yet, so skip the sweep entirely.
const labelCheck = AdsApp.labels()
.withCondition(`label.name = '${labelName}'`)
.get();
if (!labelCheck.hasNext()) {
return { alreadyLabeled, removed };
}
const labeledKeywords = AdsApp.keywords()
.withCondition(`LabelNames CONTAINS_ANY ['${labelName}']`)
.get();
while (labeledKeywords.hasNext()) {
const keyword = labeledKeywords.next();
const key = `${keyword.getAdGroup().getId()},${keyword.getId()}`;
if (qualifying.has(key)) {
alreadyLabeled.add(key); // still qualifies
} else {
keyword.removeLabel(labelName); // no longer qualifies
removed++;
}
}
return { alreadyLabeled, removed };
}
/** Applies `labelName` to keywords identified by "adGroupId,criterionId" keys. */
function applyLabel(keys, labelName) {
if (keys.length === 0) return;
// Same guard as the sweep: in Preview mode the label was never persisted,
// so applyLabel-by-name would throw. Skip (Preview still logs the counts).
const labelCheck = AdsApp.labels()
.withCondition(`label.name = '${labelName}'`)
.get();
if (!labelCheck.hasNext()) return;
// withIds() accepts up to 10,000 [adGroupId, criterionId] pairs per selector
const CHUNK_SIZE = 10000;
for (let i = 0; i < keys.length; i += CHUNK_SIZE) {
const idPairs = keys
.slice(i, i + CHUNK_SIZE)
.map((key) => key.split(',').map(Number)); // -> [adGroupId, critId]
const keywords = AdsApp.keywords().withIds(idPairs).get();
while (keywords.hasNext()) {
keywords.next().applyLabel(labelName);
}
}
}