Skip to main content

Unlocking Performance Max: How to Extract Asset-Level Metrics via Google Ads Scripts

 Performance Max (PMax) has fundamentally changed how we manage paid search, shifting control from manual keyword levers to automated, machine-learning-driven intent matching. However, for Data Analysts and PPC Engineers, this shift introduced a significant visibility problem.

PMax campaigns are frequently criticized as "black boxes." While you can easily see campaign-level CPA or ROAS, determining exactly which headline, image, or video asset triggered that conversion is notoriously difficult. The Google Ads UI buries this data in the "Asset Detail" drawer, making it impossible to analyze at scale across hundreds of asset groups.

If you are trying to programmatically retrieve granular asset performance data to build custom dashboards or automate creative refreshes, standard reporting endpoints often return empty rows.

This guide provides a robust, technical solution using Google Ads Scripts and GAQL (Google Ads Query Language) to extract asset-level performance labels from your PMax campaigns.

The Root Cause: Why PMax Data is Hard to Retrieve

Before writing the code, it is critical to understand the underlying architecture of the Google Ads API regarding Performance Max. This prevents wasted time querying the wrong resources.

In traditional Search campaigns, an ad is a static entity. In PMax, an ad is dynamically assembled. Google’s algorithm mixes and matches headlines, descriptions, images, and logos in real-time based on the user's signals.

The Attribution Disconnect

Because the "ad" technically doesn't exist until the moment of impression, Google does not attribute hard metrics (Clicks, Cost, Conversions) to a single asset (e.g., "Headline 1"). Instead, the system evaluates the asset's contribution to the overall Asset Group.

Consequently, if you attempt to query ad_group_ad_asset_view (standard for RSA) for a PMax campaign, you will fail.

The Solution: asset_group_asset

To access PMax creative data, we must query the asset_group_asset resource.

Crucially, as of the current API version, this resource does not provide numeric performance metrics (like 15 clicks or $5.00 cost). Instead, it provides a performance_label. This enum (values: BESTGOODLOWLEARNINGPENDING) is the algorithmic grading of that asset's contribution to the objective.

The script below solves the visibility problem by extracting these labels alongside the actual asset content (Text or Image URLs), allowing you to identify creative fatigue programmatically.

The Fix: Automated Asset Performance Extraction

The following Google Ads Script performs these operations:

  1. Selects all active Performance Max campaigns.
  2. Executes a GAQL query against the asset_group_asset resource.
  3. Joins data with the asset resource to retrieve the actual text or image URL.
  4. Exports a matrix of Campaign, Asset Group, Asset Type, Content, and Performance Label to a Google Sheet.

Configuration

Create a new Google Sheet. Copy the URL. You will paste this into the SPREADSHEET_URL constant below.

The Script

/**
 * PMax Asset Performance Exporter
 * Extracts asset-level performance labels (BEST, GOOD, LOW) for PMax Campaigns.
 * 
 * @author Principal Engineering Team
 * @version 2.0.0 (Modern GAQL Implementation)
 */

const CONFIG = {
  SPREADSHEET_URL: 'YOUR_SPREADSHEET_URL_HERE', // Replace with your Sheet URL
  SHEET_NAME: 'PMax_Assets',
  // Filter for specific performance labels if needed. 
  // Leave empty to fetch all: []
  LABEL_FILTER: ['LOW', 'GOOD', 'BEST'] 
};

function main() {
  Logger.log('🚀 Starting PMax Asset Extraction...');
  
  // validate spreadsheet access immediately
  const sheet = prepareSpreadsheet();
  
  // GAQL Query
  // Note: We join asset_group_asset with 'asset' to get the content (text/image)
  // and 'campaign' for context.
  const query = `
    SELECT
      campaign.name,
      asset_group.name,
      asset_group_asset.field_type,
      asset_group_asset.performance_label,
      asset_group_asset.status,
      asset.id,
      asset.name,
      asset.type,
      asset.text_asset.text,
      asset.image_asset.full_size.url,
      asset.youtube_video_asset.youtube_video_id
    FROM asset_group_asset
    WHERE
      campaign.advertising_channel_type = 'PERFORMANCE_MAX'
      AND asset_group_asset.status = 'ENABLED'
      AND campaign.status = 'ENABLED'
  `;

  // Use AdsApp.search for standard, high-performance iterator
  const searchIterator = AdsApp.search(query);
  
  const rows = [];
  let count = 0;

  // Modern iterator pattern
  for (const row of searchIterator) {
    const assetGroupAsset = row.assetGroupAsset;
    const asset = row.asset;
    const campaign = row.campaign;
    const assetGroup = row.assetGroup;

    // Normalize the performance label
    const perfLabel = assetGroupAsset.performanceLabel || 'PENDING';

    // Apply strict filtering if config is set
    if (CONFIG.LABEL_FILTER.length > 0 && !CONFIG.LABEL_FILTER.includes(perfLabel)) {
      continue;
    }

    // Determine the actual content based on asset type
    const assetContent = extractAssetContent(asset);

    rows.push([
      campaign.name,
      assetGroup.name,
      asset.id,
      assetGroupAsset.fieldType, // e.g., HEADLINE, DESCRIPTION, MARKETING_IMAGE
      assetContent,              // The actual text or URL
      perfLabel,                 // BEST, GOOD, LOW, PENDING
      assetGroupAsset.status
    ]);

    count++;
    
    // Batch write every 2000 rows to prevent memory limits
    if (rows.length >= 2000) {
      sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length).setValues(rows);
      rows.length = 0; // Clear buffer
    }
  }

  // Write remaining rows
  if (rows.length > 0) {
    sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length).setValues(rows);
  }

  Logger.log(`✅ Extraction Complete. Processed ${count} assets.`);
}

/**
 * Helper to determine what actual data to display based on asset type.
 * @param {Object} asset - The asset resource from GAQL
 * @return {string} - Text string, Image URL, or Video ID
 */
function extractAssetContent(asset) {
  if (asset.textAsset) {
    return asset.textAsset.text;
  }
  if (asset.imageAsset && asset.imageAsset.fullSize) {
    return asset.imageAsset.fullSize.url;
  }
  if (asset.youtubeVideoAsset) {
    return `https://www.youtube.com/watch?v=${asset.youtubeVideoAsset.youtubeVideoId}`;
  }
  return 'N/A';
}

/**
 * Initializes the spreadsheet with headers.
 * @return {GoogleAppsScript.Spreadsheet.Sheet}
 */
function prepareSpreadsheet() {
  const ss = SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
  let sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
  
  if (!sheet) {
    sheet = ss.insertSheet(CONFIG.SHEET_NAME);
  }
  
  sheet.clear();
  
  // Set Header Row
  const headers = [
    'Campaign Name', 
    'Asset Group', 
    'Asset ID', 
    'Field Type', 
    'Content (Text/URL)', 
    'Performance Label',
    'Status'
  ];
  
  sheet.getRange(1, 1, 1, headers.length)
       .setValues([headers])
       .setFontWeight('bold')
       .setBackground('#f3f3f3');
       
  return sheet;
}

Deep Dive: How the Query Works

The magic of this script lies in the GAQL FROM clause.

1. asset_group_asset

This is the junction object. It links a reusable asset (which lives at the account level) to a specific asset_group (the PMax equivalent of an Ad Group). This is where the performance_label lives. If you query the asset resource directly, you will not get performance data specific to the campaign.

2. Field Type Segmentation

We request asset_group_asset.field_type. This is distinct from the asset's inherent type.

  • Asset Type: IMAGE
  • Field Type: MARKETING_IMAGELOGO, or SQUARE_MARKETING_IMAGE.

Knowing the field_type is essential for analysis. A generic image performing "LOW" as a Logo might perform "BEST" as a Marketing Image. The script captures this nuance.

3. Handling null Performance

New assets often return a null or PENDING performance label. The script handles this gracefully:

const perfLabel = assetGroupAsset.performanceLabel || 'PENDING';

This ensures your data pipelines don't break when encountering new creatives that the algorithm hasn't yet graded.

Common Pitfalls and Edge Cases

When implementing this solution in a production environment, be aware of three specific edge cases that often trip up developers.

1. The "Learning" Phase

PMax requires significant data volume to assign labels. If your report returns mostly "PENDING" or "LEARNING," it is not a script error. It indicates the campaign has not reached the statistical significance threshold required by Google's backend to grade the asset.

2. Large Account Timeouts

Google Ads Scripts have a 30-minute execution limit. If you are running this across an MCC (Manager Account) with thousands of PMax asset groups, the script may time out.

  • Solution: Use the AdsApp.select(account) method to iterate through accounts, or implement script execution chaining (saving state to a spreadsheet and resuming).

3. Video Asset Privacy

For YouTube assets, the API returns the Video ID. However, if the video is set to "Private" in YouTube Studio, the script will retrieve the ID, but the generated link in the spreadsheet will be inaccessible to users without permissions. Ensure your creative team sets ads to "Unlisted" or "Public."

Conclusion

Performance Max doesn't have to be a black box. While we cannot currently fetch "Clicks per Headline" due to the dynamic nature of the ad server, the performance_label is the exact metric Google's own algorithm uses to prioritize delivery.

By automating the extraction of these labels with the script provided, you can build alerts for "LOW" performing assets, automate creative tickets for your design team, and finally bring data-driven rigor to your PMax creative strategy.