Tools · Google Ads

TierLog — Free Product Tier Segmentation for Google Ads

Automatically segments your entire product catalog by performance. Runs daily. Free forever.

The Problem

PMax treats every product the same. TierLog doesn't.

Performance Max reports aggregate campaign performance — not what individual products are doing. One flat campaign means your best performers and your budget drains are averaged together, and Google optimises across all of them without knowing the difference.

No product-level visibility

PMax shows you campaign-level ROAS. It does not tell you which of your 1,000 products are pulling the weight and which are burning the budget.

Heroes and Villains get averaged

A product returning 8x ROAS and a product returning 0.5x ROAS both sit in the same campaign. Google spreads budget across both. You can't fix what you can't see.

Invisible at scale

At 500 or 5,000 SKUs, manual product-level analysis isn't possible. Without automation, you're flying blind on the majority of your catalog.

The Six Tiers

Every product gets a label. Every label has a job.

TierLog classifies your entire product catalog into six tiers based on impressions, clicks, and ROAS over a configurable lookback window. Each tier maps to a distinct campaign strategy.

Superhero

ROAS ≥ 2× target · High clicks

Elite performers. ROAS is double your target with strong click volume. Give them the highest budgets and protect them aggressively.

Hero

ROAS ≥ target · High clicks

Proven workhorses hitting your ROAS target with real traffic. These products keep the business running — don't starve them of budget.

Sidekick

ROAS ≥ target · Low clicks

Hidden gems. Converting well but underexposed. Increase their visibility — these are the products you want to promote to Hero.

Villain

Clicks above threshold · ROAS below target

Budget drains. Getting real traffic but not converting at your ROAS target. Isolate them, tighten targets, or pause.

Zombie

≤ 100 impressions in 30 days

Barely visible. Google is willing to serve these products but almost never does. Activation needed — try a dedicated low-ROAS-target campaign.

No-Data

0 impressions

Not being served at all. This is a feed or eligibility issue, not a bidding problem. Check Merchant Center diagnostics first.

Setup

Up and running in six steps.

01

Paste TierLog into Google Ads Scripts

Go to Tools → Bulk Actions → Scripts in your Google Ads account. Create a new script and paste the full TierLog code.

02

Create a Google Sheet and add the Sheet ID

Create a new Google Sheet. Copy the ID from the URL — the long string between /d/ and /edit — and paste it into the sheetId field at the top of the script.

03

Run the script — 4 tabs create automatically

Set MODE to "setup" and run once. TierLog scaffolds your Sheet with four tabs: Config, Product Data, Labels, and Log. Then switch MODE back to "run".

04

Connect the Labels tab as a Supplemental Feed

In Google Merchant Center, go to Products → Feeds → Add Supplemental Feed → Google Sheets. Select your Sheet and choose the Labels tab.

05

Build one PMax campaign per tier

Create a separate Performance Max (or Standard Shopping) campaign for each tier. Use custom label filters to assign products. Set unique ROAS targets and budgets per tier.

06

Script runs daily at 4am — labels update automatically

Run createDailyTrigger() once to schedule daily execution. Every morning after Google's data refresh, TierLog recomputes tier labels and syncs to your Labels tab. Products graduate between tiers automatically.

Get TierLog

The script. Copy it. It's free.

Paste it into Google Ads Scripts, fill in your customerId and sheetId at the top, and run.

TierLog.gs
/**
 * ============================================================
 *  TIERLOG — Product Tier Segmentation for Google Ads
 *  Configurable Custom Label (0–4) | Shopping + Performance Max
 * ============================================================
 *
 *  SETUP:
 *  1. Create a new Google Sheet
 *  2. Go to Extensions > Apps Script, paste this file
 *  3. Fill in CONFIG below (Ads Customer ID + Sheet ID)
 *  4. Run setupSheet() once to create tabs + config rows
 *  5. Run runTierLog() manually or set a daily trigger
 *
 *  SHEETS CREATED:
 *  - Config      → editable thresholds, date range, label names
 *  - Product Data → raw pull from Google Ads
 *  - Labels      → computed labels per product (6 tiers: Superhero/Hero/Sidekick/Villain/Zombie/No-Data)
 *  - Log         → run history
 * ============================================================
 */

// ── HARDCODED CONFIG (fallback if Config sheet missing) ──────
const CONFIG = {
  customerId: "INSERT-YOUR-CUSTOMER-ID",   // e.g. "123-456-7890"
  sheetId:    "INSERT-YOUR-SHEET-ID",       // from Sheet URL
  lookbackDays: 30,
  targetRoas: 500,   // percentage (500 = 5x = 500%)

  // Thresholds — override these in Config sheet
  heroMinClicks:        50,  // clicks in lookback window
  sidekickMaxClicks:    49,  // < this AND converts well = Sidekick
  villainMaxRoas:       499, // below target ROAS with spend = Villain
  zombieMaxImpressions: 100, // ≤ this impressions = Zombie (served rarely, not a feed issue)

  // Label names written to Custom Label slot
  customLabelSlot: 4,  // 0–4 — which custom label slot to use (default: 4 = custom_label_4)
  labels: {
    hero:      "Hero",
    sidekick:  "Sidekick",
    villain:   "Villain",
    zombie:    "Zombie",
    superhero: "Superhero",  // Heroes with ROAS > 2x target
    noData:    "No-Data",    // 0 impressions in window — never shown, feed/listing issue
  }
};

// ── SHEET TAB NAMES ──────────────────────────────────────────
const TABS = {
  config:  "Config",
  data:    "Product Data",
  labels:  "Labels",
  log:     "Log"
};


// ============================================================
//  MAIN — Google Ads Scripts entry point
//  Change MODE to "setup" on first run, then switch to "run"
// ============================================================

var MODE = "run";  // "setup" | "run" | "push_only"

function main() {
  if (MODE === "setup") {
    setupSheet();
  } else if (MODE === "push_only") {
    pushLabelsOnly();
  } else {
    runTierLog();
  }
}


/**
 * Run once to scaffold the Sheet structure.
 */
function setupSheet() {
  const ss = SpreadsheetApp.openById(CONFIG.sheetId);

  _createTabIfMissing(ss, TABS.config);
  _createTabIfMissing(ss, TABS.data);
  _createTabIfMissing(ss, TABS.labels);
  _createTabIfMissing(ss, TABS.log);

  _writeConfigDefaults(ss);
  _writeDataHeaders(ss);
  _writeLabelHeaders(ss);

  Logger.log("✅ Sheet scaffolded. Fill in Config tab, then run runTierLog().");
}

/**
 * Main function — pull data, compute tiers, push to Google Ads.
 */
function runTierLog() {
  const ss       = SpreadsheetApp.openById(CONFIG.sheetId);

  _createTabIfMissing(ss, TABS.config);
  _createTabIfMissing(ss, TABS.data);
  _createTabIfMissing(ss, TABS.labels);
  _createTabIfMissing(ss, TABS.log);

  _writeConfigDefaults(ss);

  const cfg      = _loadConfig(ss);
  const startTs  = new Date();

  _log(ss, "START", `TierLog run started. Lookback: ${cfg.lookbackDays}d, Target ROAS: ${cfg.targetRoas}%`);

  try {
    const products = _fetchProductData(cfg);
    _log(ss, "INFO", `Fetched ${products.length} products from Google Ads`);

    _writeProductData(ss, products);

    const labeled = _computeLabels(products, cfg);
    _writeLabeledData(ss, labeled, cfg);

    const counts = _labelCounts(labeled);
    _log(ss, "INFO", `Labels: Superheroes=${counts.Superhero||0}, Heroes=${counts.Hero||0}, Sidekicks=${counts.Sidekick||0}, Villains=${counts.Villain||0}, Zombies=${counts.Zombie||0}, No-Data=${counts["No-Data"]||0}`);

    _log(ss, "INFO", `Wrote ${labeled.length} tier labels to Labels tab — Merchant Center Supplemental Feed will sync on next fetch`);

    const elapsed = ((new Date() - startTs) / 1000).toFixed(1);
    _log(ss, "SUCCESS", `Run complete in ${elapsed}s`);

  } catch (e) {
    _log(ss, "ERROR", e.toString());
    throw e;
  }
}

/**
 * Convenience: only push labels already computed in Labels tab (no API pull).
 */
function pushLabelsOnly() {
  const ss  = SpreadsheetApp.openById(CONFIG.sheetId);
  const cfg = _loadConfig(ss);
  const labeled = _readLabeledDataFromSheet(ss);
  const pushed  = _pushLabelsToAds(labeled, cfg);
  _log(ss, "PUSH-ONLY", `Pushed ${pushed} label updates to Google Ads`);
}


// ============================================================
//  GOOGLE ADS DATA FETCH
// ============================================================

function _fetchProductData(cfg) {
  const dateRange = _buildDateRange(cfg.lookbackDays);

  const query = `
    SELECT
      segments.product_item_id,
      segments.product_title,
      segments.product_merchant_id,
      metrics.impressions,
      metrics.clicks,
      metrics.cost_micros,
      metrics.conversions,
      metrics.conversions_value
    FROM shopping_performance_view
    WHERE
      segments.date BETWEEN '${dateRange.from}' AND '${dateRange.to}'
    ORDER BY metrics.clicks DESC
    LIMIT 50000
  `;

  const rows = [];
  const reportIterator = AdsApp.report(query).rows();

  while (reportIterator.hasNext()) {
    const row = reportIterator.next();
    const costMicros = parseFloat(row["metrics.cost_micros"]) || 0;
    const convValue  = parseFloat(row["metrics.conversions_value"]) || 0;
    const cost       = costMicros / 1000000;
    const roas       = cost > 0 ? (convValue / cost) * 100 : 0;

    rows.push({
      itemId:       (row["segments.product_item_id"] || "").replace(/_in_/i, "_IN_"),
      title:        row["segments.product_title"],
      merchantId:   row["segments.product_merchant_id"],
      impressions:  parseInt(row["metrics.impressions"]) || 0,
      clicks:       parseInt(row["metrics.clicks"]) || 0,
      cost:         cost,
      conversions:  parseFloat(row["metrics.conversions"]) || 0,
      convValue:    convValue,
      roas:         roas,
      label:        ""
    });
  }

  return rows;
}


// ============================================================
//  LABELING LOGIC
// ============================================================

/**
 * Decision tree (order matters):
 *
 *  1. No-Data   — impressions = 0. Feed/eligibility issue. Check Merchant Center.
 *  2. Zombie    — impressions > 0 but ≤ zombieMaxImpressions. Barely visible.
 *  3. Villain   — impressions > threshold, ROAS < target. Budget drain.
 *  4. Sidekick  — clicks < heroMinClicks, ROAS ≥ target. Hidden gem.
 *  5. Hero      — clicks ≥ heroMinClicks, ROAS ≥ target. Proven performer.
 *  6. Superhero — Hero with ROAS ≥ 2× target. Elite tier.
 */
function _computeLabels(products, cfg) {
  const superRoasThreshold = cfg.targetRoas * 2;

  return products.map(p => {
    let label;

    if (p.impressions === 0) {
      label = cfg.labels.noData;
    } else if (p.impressions <= cfg.zombieMaxImpressions) {
      label = cfg.labels.zombie;
    } else if (p.roas < cfg.targetRoas) {
      label = cfg.labels.villain;
    } else if (p.clicks < cfg.heroMinClicks) {
      label = cfg.labels.sidekick;
    } else if (p.roas >= superRoasThreshold) {
      label = cfg.labels.superhero;
    } else {
      label = cfg.labels.hero;
    }

    return { ...p, label };
  });
}


// ============================================================
//  PUSH LABELS TO GOOGLE ADS
// ============================================================

function _pushLabelsToAds(labeled, cfg) {
  let pushed = 0;

  const labelMap = {};
  labeled.forEach(p => {
    if (p.itemId) labelMap[p.itemId] = p.label;
  });

  const campaignIterator = AdsApp.shoppingCampaigns().get();
  while (campaignIterator.hasNext()) {
    const campaign = campaignIterator.next();
    const pgIterator = campaign.productGroups().get();

    while (pgIterator.hasNext()) {
      const pg = pgIterator.next();
      try {
        const dim = pg.getDimension();
        if (dim && dim.productItemId) {
          const label = labelMap[dim.productItemId];
          if (label) { pushed++; }
        }
      } catch(e) { /* some product groups don't expose getDimension */ }
    }
  }

  Logger.log("ℹ️  Label data written to 'Labels' sheet tab. Connect this tab as a " +
             "Supplemental Feed in Merchant Center to apply labels.");

  return labeled.length;
}


// ============================================================
//  SHEET READ/WRITE HELPERS
// ============================================================

function _writeProductData(ss, products) {
  const sheet = ss.getSheetByName(TABS.data);
  sheet.clearContents();

  const headers = ["Item ID", "Title", "Merchant ID", "Impressions",
                   "Clicks", "Cost (€/$)", "Conversions", "Conv. Value", "ROAS (%)"];
  sheet.getRange(1, 1, 1, headers.length).setValues([headers])
    .setFontWeight("bold")
    .setBackground("#1a1a2e")
    .setFontColor("#ffffff");

  if (products.length === 0) return;

  const rows = products.map(p => [
    p.itemId, p.title, p.merchantId,
    p.impressions, p.clicks,
    p.cost.toFixed(2),
    p.conversions.toFixed(1),
    p.convValue.toFixed(2),
    p.roas.toFixed(1)
  ]);

  sheet.getRange(2, 1, rows.length, headers.length).setValues(rows);
  sheet.setFrozenRows(1);
  sheet.autoResizeColumns(1, headers.length);
}

function _writeLabeledData(ss, labeled, cfg) {
  const sheet = ss.getSheetByName(TABS.labels);
  sheet.clearContents();

  const customLabelCol = "custom_label_" + cfg.customLabelSlot;

  const headers = ["id", customLabelCol,
                   "title_[display only]", "clicks_[display only]",
                   "roas_[display only]", "cost_[display only]"];

  sheet.getRange(1, 1, 1, headers.length).setValues([headers])
    .setFontWeight("bold")
    .setBackground("#0f3460")
    .setFontColor("#e94560");

  if (labeled.length === 0) return;

  const rows = labeled.map(p => [
    p.itemId, p.label, p.title,
    p.clicks, p.roas.toFixed(1), p.cost.toFixed(2)
  ]);

  sheet.getRange(2, 1, rows.length, headers.length).setValues(rows);

  const labelCol = sheet.getRange(2, 2, rows.length, 1);
  const colors   = labeled.map(p => [_labelColor(p.label)]);
  labelCol.setBackgrounds(colors);
  labelCol.setFontWeight("bold");

  sheet.setFrozenRows(1);
  sheet.autoResizeColumns(1, headers.length);
}

function _readLabeledDataFromSheet(ss) {
  const sheet = ss.getSheetByName(TABS.labels);
  const data  = sheet.getDataRange().getValues();
  if (data.length < 2) return [];

  return data.slice(1).map(row => ({
    itemId: row[0], label: row[1], title: row[2],
    clicks: row[3], roas: parseFloat(row[4]) || 0, cost: parseFloat(row[5]) || 0
  }));
}


// ============================================================
//  CONFIG SHEET
// ============================================================

function _writeConfigDefaults(ss) {
  const sheet = ss.getSheetByName(TABS.config);
  if (sheet.getLastRow() > 1) return;

  sheet.clearContents();
  const headers = ["Setting", "Value", "Description"];
  sheet.getRange(1, 1, 1, 3).setValues([headers])
    .setFontWeight("bold").setBackground("#0f3460").setFontColor("#e94560");

  const rows = [
    ["lookback_days",          30,   "Number of days of data to pull"],
    ["target_roas",            500,  "Target ROAS % (500 = 5x)"],
    ["custom_label_slot",      4,    "Which custom label slot to use (0, 1, 2, 3, or 4)"],
    ["hero_min_clicks",        50,   "Min clicks to qualify as Hero/Villain (not Sidekick)"],
    ["sidekick_max_clicks",    49,   "Max clicks for a well-converting Sidekick"],
    ["villain_max_roas",       499,  "Max ROAS% that still counts as Villain (below target)"],
    ["zombie_max_impressions", 100,  "≤ this impressions = Zombie"],
    ["label_hero",       "Hero",      "Tier value written to your chosen custom label slot"],
    ["label_sidekick",   "Sidekick",  "Tier value written to your chosen custom label slot"],
    ["label_villain",    "Villain",   "Tier value written to your chosen custom label slot"],
    ["label_zombie",     "Zombie",    "Tier value written to your chosen custom label slot"],
    ["label_superhero",  "Superhero", "Tier value written to your chosen custom label slot"],
    ["label_no_data",    "No-Data",   "Tier value written to your chosen custom label slot"],
  ];

  sheet.getRange(2, 1, rows.length, 3).setValues(rows);
  sheet.autoResizeColumns(1, 3);
  sheet.setFrozenRows(1);
}

function _loadConfig(ss) {
  const sheet = ss.getSheetByName(TABS.config);
  if (!sheet) return CONFIG;

  const data = sheet.getDataRange().getValues();
  const map  = {};
  data.slice(1).forEach(row => { if (row[0]) map[row[0]] = row[1]; });

  return {
    lookbackDays:         parseInt(map["lookback_days"])          || CONFIG.lookbackDays,
    targetRoas:           parseFloat(map["target_roas"])          || CONFIG.targetRoas,
    customLabelSlot:      parseInt(map["custom_label_slot"])      >= 0 ? parseInt(map["custom_label_slot"]) : CONFIG.customLabelSlot,
    heroMinClicks:        parseInt(map["hero_min_clicks"])        || CONFIG.heroMinClicks,
    sidekickMaxClicks:    parseInt(map["sidekick_max_clicks"])    || CONFIG.sidekickMaxClicks,
    villainMaxRoas:       parseFloat(map["villain_max_roas"])     || CONFIG.villainMaxRoas,
    zombieMaxImpressions: parseInt(map["zombie_max_impressions"]) || CONFIG.zombieMaxImpressions,
    labels: {
      hero:      map["label_hero"]      || CONFIG.labels.hero,
      sidekick:  map["label_sidekick"]  || CONFIG.labels.sidekick,
      villain:   map["label_villain"]   || CONFIG.labels.villain,
      zombie:    map["label_zombie"]    || CONFIG.labels.zombie,
      superhero: map["label_superhero"] || CONFIG.labels.superhero,
      noData:    map["label_no_data"]   || CONFIG.labels.noData,
    }
  };
}


// ============================================================
//  LOG TAB
// ============================================================

function _writeDataHeaders(ss) {
  const sheet = ss.getSheetByName(TABS.data);
  if (sheet.getLastRow() === 0) {
    sheet.getRange(1,1).setValue("Run setupSheet() and then runTierLog() to populate this tab.");
  }
}

function _writeLabelHeaders(ss) {
  const sheet = ss.getSheetByName(TABS.labels);
  if (sheet.getLastRow() === 0) {
    sheet.getRange(1,1).setValue("Run runTierLog() to populate this tab. Then connect it as a Supplemental Feed in Merchant Center.");
  }
}

function _log(ss, level, message) {
  const sheet = ss.getSheetByName(TABS.log);
  if (!sheet) return;
  if (sheet.getLastRow() === 0) {
    sheet.getRange(1,1,1,3).setValues([["Timestamp","Level","Message"]])
      .setFontWeight("bold").setBackground("#0f3460").setFontColor("#e94560");
    sheet.setFrozenRows(1);
  }
  sheet.appendRow([new Date().toISOString(), level, message]);
  Logger.log(`[${level}] ${message}`);
}


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

function _buildDateRange(days) {
  const to   = new Date();
  const from = new Date();
  from.setDate(to.getDate() - days);
  return {
    from: Utilities.formatDate(from, "UTC", "yyyy-MM-dd"),
    to:   Utilities.formatDate(to,   "UTC", "yyyy-MM-dd")
  };
}

function _createTabIfMissing(ss, name) {
  if (!ss.getSheetByName(name)) {
    ss.insertSheet(name);
    Logger.log(`Created tab: ${name}`);
  }
}

function _labelCounts(labeled) {
  return labeled.reduce((acc, p) => {
    acc[p.label] = (acc[p.label] || 0) + 1;
    return acc;
  }, {});
}

function _labelColor(label) {
  const map = {
    "Superhero": "#cfe2ff",
    "Hero":      "#d4edda",
    "Sidekick":  "#fff3cd",
    "Villain":   "#f8d7da",
    "Zombie":    "#e2e3e5",
    "No-Data":   "#1a1a1a",
  };
  return map[label] || "#ffffff";
}


// ============================================================
//  TRIGGER HELPER — run once to schedule daily execution
// ============================================================

/**
 * Creates a daily time-based trigger for runTierLog().
 * Run this function once from the Apps Script editor.
 */
function createDailyTrigger() {
  ScriptApp.getProjectTriggers().forEach(t => {
    if (t.getHandlerFunction() === "runTierLog") {
      ScriptApp.deleteTrigger(t);
    }
  });

  ScriptApp.newTrigger("runTierLog")
    .timeBased()
    .everyDays(1)
    .atHour(4)
    .create();

  Logger.log("✅ Daily trigger created for runTierLog() at 4am.");
}

Fill in your customerId and sheetId at the top before running.

Set custom_label_slot in the Config tab to whichever label slot (0–4) is free in your account.

FAQ

Common questions.

Does this work for Standard Shopping campaigns?+

Yes — the segmentation logic works for both Standard Shopping and Performance Max. The GAQL query pulls from shopping_performance_view, which covers both campaign types.

Will it overwrite my existing custom labels?+

Only the slot you configure. Set custom_label_slot in the Config tab to whichever label slot (0–4) you are not currently using. The script only touches the one slot you assign it.

How often does it run?+

Daily at 4am after Google's overnight data refresh. You can change the hour in the createDailyTrigger() function at the bottom of the script.

Why are all my products showing as Zombie?+

Your zombie_max_impressions threshold may be too high for your account size. Lower it in the Config tab. For smaller accounts, try setting it to 10–20 instead of the default 100.

My IDs aren't matching in Merchant Center — what do I do?+

Check the exact format of your product IDs in GMC (Products → All Products → open any product). The script normalises the _IN_ country code to uppercase but your feed may use a different format. Check and adjust the .replace() line in _fetchProductData if needed.

Built by Jigar Soni

A tool I built because I needed it.

Managing ₹70L+ in monthly ad spend across Shopping and PMax accounts, product-level visibility was something I needed but couldn't find. TierLog is free, open, and does exactly what it says.