What it does
Slugifies every enabled campaign and ad group name into {_campaign} and {_adgroup} custom parameters, then applies a tracking template to sitelinks attached to enabled campaigns. Sitelink clicks get utm_campaign, utm_adgroup, and utm_content=sitelink so you can see sitelink performance separately in analytics instead of lumping it with the ad URL. Runs in preview mode by default (APPLY_CHANGES: false) so you can see exactly what would change before anything is written.
The code
/**
* Google Ads Script: Sitelink Tracking via Custom Parameters
*
* 1. Sets {_campaign} custom param on all enabled campaigns (slugified name)
* 2. Sets {_adgroup} custom param on all enabled ad groups (slugified name)
* 3. Applies tracking template to sitelinks attached to enabled campaigns
*
* Schedule: Run every 30 min alongside your existing suffix script,
* or daily if campaign/ad group names don't change often.
*/
var CONFIG = {
APPLY_CHANGES: true, // Preview mode. Flip to true when ready.
SITELINK_TRACKING_TEMPLATE: '{lpurl}?utm_source=google&utm_medium=cpc&utm_campaign={_campaign}&gclid={gclid}&utm_adgroup={_adgroup}&utm_term={keyword}&utm_content=sitelink&utm_device={device}&utm_matchtype={matchtype}&utm_network={network}&utm_location={loc_physical_ms}',
// Set true to also update sitelinks that already have a tracking template
OVERWRITE_EXISTING_SITELINK_TEMPLATES: false,
// Only process enabled entities
SKIP_PAUSED: true,
};
function main() {
var stats = {
campaignsUpdated: 0,
campaignsSkipped: 0,
adGroupsUpdated: 0,
adGroupsSkipped: 0,
sitelinksUpdated: 0,
sitelinksSkipped: 0,
};
// --- Step 1: Set {_campaign} on all campaigns ---
Logger.log('========== CAMPAIGNS ==========');
var campaignSelector = AdsApp.campaigns();
if (CONFIG.SKIP_PAUSED) {
campaignSelector = campaignSelector.withCondition('Status = ENABLED');
}
var campaigns = campaignSelector.get();
while (campaigns.hasNext()) {
var campaign = campaigns.next();
var name = campaign.getName();
var slug = slugify(name);
if (slug.length > 200) {
slug = slug.substring(0, 200);
}
var params = campaign.urls().getCustomParameters();
if (params['campaign'] === slug) {
stats.campaignsSkipped++;
continue;
}
if (CONFIG.APPLY_CHANGES) {
params['campaign'] = slug;
campaign.urls().setCustomParameters(params);
Logger.log('CAMPAIGN SET: "' + name + '" -> {_campaign} = ' + slug);
} else {
Logger.log('CAMPAIGN PREVIEW: "' + name + '" -> {_campaign} = ' + slug);
}
stats.campaignsUpdated++;
}
// --- Step 2: Set {_adgroup} on all ad groups ---
Logger.log('========== AD GROUPS ==========');
var adGroupSelector = AdsApp.adGroups();
if (CONFIG.SKIP_PAUSED) {
adGroupSelector = adGroupSelector.withCondition('Status = ENABLED');
}
var adGroups = adGroupSelector.get();
while (adGroups.hasNext()) {
var adGroup = adGroups.next();
var agName = adGroup.getName();
var agSlug = slugify(agName);
if (agSlug.length > 200) {
agSlug = agSlug.substring(0, 200);
}
var agParams = adGroup.urls().getCustomParameters();
if (agParams['adgroup'] === agSlug) {
stats.adGroupsSkipped++;
continue;
}
if (CONFIG.APPLY_CHANGES) {
agParams['adgroup'] = agSlug;
adGroup.urls().setCustomParameters(agParams);
Logger.log('ADGROUP SET: "' + adGroup.getCampaign().getName() + ' > ' + agName + '" -> {_adgroup} = ' + agSlug);
} else {
Logger.log('ADGROUP PREVIEW: "' + adGroup.getCampaign().getName() + ' > ' + agName + '" -> {_adgroup} = ' + agSlug);
}
stats.adGroupsUpdated++;
}
// --- Step 3: Apply tracking template to sitelinks on enabled campaigns ---
Logger.log('========== SITELINKS ==========');
var processedSitelinks = {};
var enabledCampaigns = AdsApp.campaigns()
.withCondition('Status = ENABLED')
.get();
while (enabledCampaigns.hasNext()) {
var camp = enabledCampaigns.next();
var campSitelinks = camp.extensions().sitelinks().get();
while (campSitelinks.hasNext()) {
var sitelink = campSitelinks.next();
var linkText = sitelink.getLinkText();
var finalUrl = sitelink.urls().getFinalUrl();
var dedupeKey = linkText + '|' + finalUrl;
// Skip if already processed (shared sitelink across campaigns)
if (processedSitelinks[dedupeKey]) {
continue;
}
processedSitelinks[dedupeKey] = true;
var existingTemplate = sitelink.urls().getTrackingTemplate();
if (existingTemplate && existingTemplate.length > 0 && !CONFIG.OVERWRITE_EXISTING_SITELINK_TEMPLATES) {
stats.sitelinksSkipped++;
Logger.log('SITELINK SKIP: "' + linkText + '" (has template)');
continue;
}
if (CONFIG.APPLY_CHANGES) {
sitelink.urls().setTrackingTemplate(CONFIG.SITELINK_TRACKING_TEMPLATE);
Logger.log('SITELINK SET: "' + linkText + '" (via ' + camp.getName() + ')');
} else {
Logger.log('SITELINK PREVIEW: "' + linkText + '" (via ' + camp.getName() + ')');
}
stats.sitelinksUpdated++;
}
}
// --- Summary ---
Logger.log('========== SUMMARY ==========');
Logger.log('Mode: ' + (CONFIG.APPLY_CHANGES ? 'LIVE' : 'PREVIEW'));
Logger.log('Campaigns -> Updated: ' + stats.campaignsUpdated + ' | Skipped: ' + stats.campaignsSkipped);
Logger.log('Ad Groups -> Updated: ' + stats.adGroupsUpdated + ' | Skipped: ' + stats.adGroupsSkipped);
Logger.log('Sitelinks -> Updated: ' + stats.sitelinksUpdated + ' | Skipped: ' + stats.sitelinksSkipped);
}
/**
* Converts name to URL-safe string.
* Spaces become %20 to match existing suffix script convention.
*/
function slugify(text) {
return text
.replace(/\s/g, '%20');
}
When to use this
Use this after your standard campaign/ad group tracking is in place, when you want sitelink extensions tracked independently (utm_content=sitelink) rather than falling through to the ad's template. It's the companion to a UTM tracking-template script and is especially useful when sitelinks are shared across campaigns and would otherwise lose their campaign/ad group context.
Prerequisites
- Script access at the account or MCC level (Tools & Settings > Bulk Actions > Scripts).
- Ability to authorize the script to run on your account.
How to implement
- 1
Create a new script
Tools & Settings > Bulk Actions > Scripts > + New Script. Paste the full file.
- 2
Review the CONFIG block
Check SITELINK_TRACKING_TEMPLATE matches your naming convention. Leave APPLY_CHANGES false for the first run.
- 3
Run in preview mode
Run the script with APPLY_CHANGES: false and check View > Logs. It prints every campaign, ad group, and sitelink that would be updated, so you can confirm the slugified names resolve correctly.
- 4
Flip to live mode
Set APPLY_CHANGES: true and run again. Re-running is idempotent — already-matching custom params and sitelinks with an existing template are skipped unless OVERWRITE_EXISTING_SITELINK_TEMPLATES is true.
- 5
Schedule it
Schedule every 30 minutes if campaign/ad group names change often, or daily if they rarely change.
Caveats
Sitelinks shared across multiple campaigns are only processed once (deduplicated by link text + final URL). Custom parameter values are the slugified name with spaces as %20, not a full URL-encoded string. If OVERWRITE_EXISTING_SITELINK_TEMPLATES is false, sitelinks that already have a tracking template are left untouched. Preview mode makes no changes at all — always review the log before going live.