Skip to content

Add creative-level targeting support (language, geo, device) #414

@bokelley

Description

@bokelley

Problem

GAM supports creative-level targeting via LineItemCreativeAssociation (LICA) with targetingName, but we currently don't use this feature. This means:

  1. All creatives in a line item get the same targeting - can't show different creatives to different audiences
  2. No language-specific creative delivery - can't show English creative to English users and Spanish creative to Spanish users in the same line item
  3. Limited flexibility - forces creation of multiple line items for simple creative variations

What GAM Supports (But We Don't Use)

GAM allows targeting at two levels:

Line Item Level (Currently Used)

line_item = {
    "targeting": {
        "geoTargeting": {...},  # Applies to ALL creatives
        "browserLanguageTargeting": {...}  # Applies to ALL creatives
    }
}

Creative Level (NOT Currently Used)

line_item = {
    "creativeTargetings": [
        {
            "name": "english_targeting",
            "targeting": {
                "browserLanguageTargeting": {
                    "isTargeted": True,
                    "browserLanguages": [{"id": 1000}]  # English
                }
            }
        },
        {
            "name": "spanish_targeting",
            "targeting": {
                "browserLanguageTargeting": {
                    "isTargeted": True,
                    "browserLanguages": [{"id": 1003}]  # Spanish
                }
            }
        }
    ],
    "creativePlaceholders": [
        {"size": {"width": 728, "height": 90}, "targetingName": "english_targeting"},
        {"size": {"width": 728, "height": 90}, "targetingName": "spanish_targeting"}
    ]
}

# Then associate creatives with targeting
lica_english = {
    "lineItemId": line_item_id,
    "creativeId": english_creative_id,
    "targetingName": "english_targeting"  # Links to creativeTargeting
}

lica_spanish = {
    "lineItemId": line_item_id,
    "creativeId": spanish_creative_id,
    "targetingName": "spanish_creative"
}

Use Cases

  1. Multi-language campaigns - Show language-appropriate creative based on browser language
  2. Geo-specific creatives - Show different creatives to US vs Canada within same campaign
  3. Device-specific creatives - Show mobile-optimized creative to mobile users
  4. A/B testing - Test different creatives against different audience segments

Blocked By: AdCP Spec Extension

⚠️ CANNOT IMPLEMENT YET - Waiting on AdCP v2.5+ to add creative-level targeting.

Current AdCP spec (v2.4) only supports:

  • Line item-level targeting via TargetingOverlay
  • Package-level targeting (which becomes line item targeting)
  • No creative-level targeting specification

Proposed AdCP Extension

Option 1: Add to CreativeAsset
```python
class CreativeAsset:
creative_id: str
# ... existing fields ...
targeting: TargetingOverlay | None = None # NEW: Creative-specific targeting
```

Option 2: Add to Package (map creative_id → targeting)
```python
class MediaPackage:
# ... existing fields ...
creative_targeting: dict[str, TargetingOverlay] | None = None # NEW: Map creative_id to targeting
```

Option 3: New field on TargetingOverlay for language
```python
class TargetingOverlay:
# ... existing fields ...
language_any_of: list[str] | None = None # NEW: ISO 639-1 codes (en, es, fr, etc.)
language_none_of: list[str] | None = None
```

Next Steps for AdCP

  1. Propose to AdCP Working Group - Present use case and proposed schema
  2. Get consensus - Agreement on schema design (Option 1, 2, or 3)
  3. Publish AdCP v2.5 - Include creative-level targeting
  4. Implement in this codebase - Once spec is finalized

Implementation Plan (After AdCP Spec Updated)

Phase 1: Language Targeting Infrastructure

1.1 Add Language Lookup Service

# src/adapters/gam/managers/language.py
class GAMLanguageManager:
    """Manages language lookup and mapping for GAM."""
    
    def get_available_languages(self) -> list[dict]:
        """Query GAM API for available browser languages."""
        
    def iso_to_gam_id(self, iso_code: str) -> int | None:
        """Map ISO 639-1 language code to GAM language ID.
        
        Examples:
            "en" -> 1000
            "es" -> 1003
            "fr" -> 1001
        """

1.2 Add Language ID Configuration

# In tenant settings or product config
"language_id_map": {
    "en": 1000,
    "es": 1003,
    "fr": 1001,
    "de": 1002,
    # ... pre-configured for common languages
}

1.3 Extend Targeting Builder for Language

# In src/adapters/gam/managers/targeting.py::build_targeting()

if targeting_overlay.language_any_of:
    browser_languages = []
    for iso_code in targeting_overlay.language_any_of:
        gam_id = language_manager.iso_to_gam_id(iso_code)
        if gam_id:
            browser_languages.append({"id": gam_id})
        else:
            raise ValueError(f"Language '{iso_code}' not supported in GAM network")
    
    gam_targeting["browserLanguageTargeting"] = {
        "isTargeted": True,
        "browserLanguages": browser_languages
    }

Phase 2: Creative-Level Targeting Core

2.1 Extend LineItem Creation

# In src/adapters/gam/managers/orders.py::create_line_items()

def create_line_items(
    self, 
    ...,
    creative_level_targeting: dict[str, dict] = None  # NEW parameter
):
    """Create line items with optional creative-level targeting.
    
    Args:
        creative_level_targeting: Map targeting names to targeting configs
            Example:
            {
                "english_targeting": {
                    "browserLanguageTargeting": {
                        "isTargeted": True,
                        "browserLanguages": [{"id": 1000}]
                    }
                },
                "spanish_targeting": {...}
            }
    """
    
    line_item = {...}
    
    # Add creative targetings if provided
    if creative_level_targeting:
        line_item["creativeTargetings"] = []
        for targeting_name, targeting_config in creative_level_targeting.items():
            line_item["creativeTargetings"].append({
                "name": targeting_name,
                "targeting": targeting_config
            })
        
        # Update creative placeholders to reference targeting names
        for placeholder in line_item["creativePlaceholders"]:
            # Assign targetingName based on format/size/assignment logic
            placeholder["targetingName"] = get_targeting_name_for_placeholder(placeholder)

2.2 Extend LICA Creation

# In src/adapters/gam/managers/creatives.py::_associate_creative_with_line_items()

def _associate_creative_with_line_items(
    self,
    gam_creative_id: str,
    asset: dict[str, Any],
    line_item_map: dict[str, str],
    lica_service,
    targeting_name: str = None  # NEW parameter
):
    """Associate creative with line items, optionally with creative-level targeting."""
    
    association = {
        "creativeId": gam_creative_id,
        "lineItemId": line_item_id,
    }
    
    # Add targeting name if creative-level targeting is used
    if targeting_name:
        association["targetingName"] = targeting_name
    
    lica_service.createLineItemCreativeAssociations([association])

2.3 Add Targeting Name Generation

# In src/adapters/gam/utils/targeting.py (new file)

def generate_targeting_name(creative_id: str, targeting: TargetingOverlay) -> str:
    """Generate unique targeting name for creative-level targeting.
    
    Examples:
        Creative with English language targeting:
            -> "creative_abc123_lang_en"
        
        Creative with geo + language targeting:
            -> "creative_abc123_geo_us_lang_en"
    """
    parts = [f"creative_{creative_id}"]
    
    if targeting.language_any_of:
        lang_codes = "_".join(sorted(targeting.language_any_of))
        parts.append(f"lang_{lang_codes}")
    
    if targeting.geo_country_any_of:
        countries = "_".join(sorted(targeting.geo_country_any_of))
        parts.append(f"geo_{countries}")
    
    return "_".join(parts)

Phase 3: AdCP Integration

3.1 Parse Creative-Level Targeting from AdCP Request

# In src/core/main.py::_create_media_buy_impl()

# After AdCP spec updated to support creative targeting
for package in packages:
    creative_level_targeting = {}
    
    # Option 1: From CreativeAsset.targeting
    for asset in assets:
        if asset.targeting:
            targeting_name = generate_targeting_name(asset.creative_id, asset.targeting)
            gam_targeting = targeting_manager.build_targeting(asset.targeting)
            creative_level_targeting[targeting_name] = gam_targeting
    
    # Option 2: From Package.creative_targeting
    if package.creative_targeting:
        for creative_id, targeting in package.creative_targeting.items():
            targeting_name = generate_targeting_name(creative_id, targeting)
            gam_targeting = targeting_manager.build_targeting(targeting)
            creative_level_targeting[targeting_name] = gam_targeting
    
    # Pass to line item creation
    line_item_ids = orders_manager.create_line_items(
        ...,
        creative_level_targeting=creative_level_targeting
    )

3.2 Map Creative Assets to Targeting Names

# When associating creatives, determine targeting name
for asset in assets:
    gam_creative_id = create_creative(asset)
    
    # Determine targeting name from asset
    if asset.targeting:  # Option 1
        targeting_name = generate_targeting_name(asset.creative_id, asset.targeting)
    elif package.creative_targeting and asset.creative_id in package.creative_targeting:  # Option 2
        targeting = package.creative_targeting[asset.creative_id]
        targeting_name = generate_targeting_name(asset.creative_id, targeting)
    else:
        targeting_name = None  # No creative-level targeting
    
    # Associate with targeting name
    associate_creative_with_line_items(
        gam_creative_id,
        asset,
        line_item_map,
        lica_service,
        targeting_name=targeting_name
    )

Phase 4: Validation & Error Handling

4.1 Validate Creative Targeting Compatibility

def validate_creative_targeting(
    line_item_targeting: dict,
    creative_targeting: dict
) -> list[str]:
    """Validate creative targeting is compatible with line item targeting.
    
    GAM Rule: Creative targeting must be a subset of or consistent with line item targeting.
    
    Example Invalid:
        Line item targets: US only
        Creative targets: Canada  <- ERROR: Creative targeting outside line item scope
    
    Example Valid:
        Line item targets: US + Canada
        Creative 1 targets: US     <- OK: Subset of line item
        Creative 2 targets: Canada <- OK: Subset of line item
    """
    errors = []
    
    # Validate geo targeting
    # Validate language targeting
    # Validate other targeting dimensions
    
    return errors

4.2 Handle Networks Without Creative Targeting

# Check if feature is enabled in GAM network
try:
    line_item = create_line_item_with_creative_targeting(...)
except Exception as e:
    if "creative targeting" in str(e).lower():
        raise ValueError(
            "Creative-level targeting is not enabled in your GAM network. "
            "Contact Google support to enable this feature or create separate line items "
            "for each targeting variant."
        )
    raise

Phase 5: Testing

5.1 Unit Tests

def test_language_targeting_name_generation():
    """Test targeting name generation for language targeting."""
    targeting = TargetingOverlay(language_any_of=["en", "es"])
    name = generate_targeting_name("creative_123", targeting)
    assert name == "creative_123_lang_en_es"

def test_creative_targeting_builds_correctly():
    """Test GAM creative targeting structure."""
    creative_targeting = {
        "english_targeting": {
            "browserLanguageTargeting": {
                "isTargeted": True,
                "browserLanguages": [{"id": 1000}]
            }
        }
    }
    
    line_item = build_line_item_with_creative_targeting(
        ...,
        creative_level_targeting=creative_targeting
    )
    
    assert "creativeTargetings" in line_item
    assert len(line_item["creativeTargetings"]) == 1
    assert line_item["creativeTargetings"][0]["name"] == "english_targeting"

5.2 Integration Tests

@pytest.mark.integration
def test_multi_language_creative_delivery(gam_client):
    """Test creating line item with language-specific creatives."""
    
    # Create line item with creative-level language targeting
    line_item_id = create_media_buy(
        packages=[{
            "name": "Multi-language Package",
            "creative_targeting": {
                "creative_en": TargetingOverlay(language_any_of=["en"]),
                "creative_es": TargetingOverlay(language_any_of=["es"])
            }
        }],
        assets=[
            {"creative_id": "creative_en", "name": "English Ad"},
            {"creative_id": "creative_es", "name": "Spanish Ad"}
        ]
    )
    
    # Verify LICAs have correct targeting names
    licas = gam_client.get_line_item_creative_associations(line_item_id)
    assert len(licas) == 2
    
    english_lica = next(l for l in licas if l["creativeId"] == "creative_en_gam_id")
    assert english_lica["targetingName"] == "creative_creative_en_lang_en"
    
    spanish_lica = next(l for l in licas if l["creativeId"] == "creative_es_gam_id")
    assert spanish_lica["targetingName"] == "creative_creative_es_lang_es"

5.3 E2E Tests

@pytest.mark.e2e
def test_language_targeting_end_to_end():
    """Test complete flow from AdCP request to GAM delivery."""
    
    # Send AdCP create_media_buy request with language targeting
    response = create_media_buy(
        packages=[{
            "package_id": "multi_lang_pkg",
            "creative_targeting": {
                "creative_123": {
                    "language_any_of": ["en"]
                },
                "creative_456": {
                    "language_any_of": ["es"]
                }
            }
        }],
        creative_assets=[...]
    )
    
    # Verify media buy created successfully
    assert response.status == "success"
    
    # Verify GAM line item has creative targetings
    # Verify LICAs have correct targeting names
    # Verify creatives serve to correct language users

Documentation Updates

  • Update docs/gam-creative-level-targeting.md with final implementation
  • Update docs/gam-targeting-capabilities.md with language targeting examples
  • Add API documentation for new parameters
  • Add user guide for multi-language campaigns
  • Update AdCP compliance documentation

Success Criteria

  • Can create line item with multiple creativeTargetings
  • Can associate creatives with specific targeting names via LICA
  • Language targeting works (English creative to English users, Spanish to Spanish)
  • Other targeting types work at creative level (geo, device, etc.)
  • Validation prevents incompatible creative targeting
  • Clear error messages when feature not enabled in GAM network
  • All tests passing (unit, integration, e2e)
  • Documentation complete and accurate

Timeline

  • Blocked: Waiting for AdCP spec update
  • Phase 1-2: 2 weeks (language infrastructure + core creative targeting)
  • Phase 3: 1 week (AdCP integration)
  • Phase 4: 1 week (validation & error handling)
  • Phase 5: 1 week (testing)
  • Total: ~5 weeks after AdCP spec finalized

References

Related Issues

  • #TBD - Add device type targeting to GAM adapter
  • #TBD - Add browser/OS targeting to GAM adapter
  • #TBD - Propose AdCP v2.5 spec extension for creative-level targeting

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions