---
name: paid-media-tracking
description: >
  Implementación técnica de herramientas para campañas de publicidad pagada:
  Google Ads API, análisis de search terms, conversión tracking server-side,
  auditoría de cuentas programática y dashboards de rendimiento. Cargar cuando
  se construya una integración con Google Ads API, se implemente análisis
  automático de search terms, se diseñe un sistema de medición de ROAS,
  o se audite la implementación técnica de conversión tracking.
version: "1.0.0"
herramientasPermitidas: [Read]
evolvable: true  # default para skill estandar
exclusiones:
  - "No cargar para tracking de conversiones en el browser o server-side con GTM, GA4, Meta CAPI — para implementación de tracking web cargar `tracking-measurement`."
  - "No cargar para estrategia de pujas, definición de presupuesto o copy de anuncios — esas son decisiones de negocio y marketing, no técnicas."
  - "No cargar para integraciones con plataformas distintas a Google Ads (Meta Ads, TikTok Ads, LinkedIn Ads) sin relación con la API de Google — para otras plataformas usar el SDK correspondiente."
  - "No cargar para dashboards de métricas generales de negocio sin datos de Google Ads — para dashboards de observabilidad de sistemas cargar `monitoring-alertas`."
---
# paid-media-tracking — Herramientas técnicas para publicidad pagada

## Cuándo NO cargar

- La tarea es tracking de conversiones en el browser o server-side (GTM, GA4, Meta CAPI): cargar `tracking-measurement`.
- La tarea es estrategia de pujas, presupuesto o copy de anuncios: son decisiones de negocio, no de ingeniería.
- La integración es con Meta Ads, TikTok Ads o LinkedIn Ads sin relación con Google Ads API: usar el SDK del proveedor.
- La tarea es un dashboard de métricas generales de negocio sin datos específicos de Google Ads: cargar `monitoring-alertas`.

## Cuándo cargar este skill

```
✅ Construir reportes automáticos de Google Ads (Python, Node)
✅ Implementar un sistema de alertas sobre rendimiento de campañas
✅ Integrar Google Ads API con un CRM o backend
✅ Diseñar el schema de dataLayer para campañas de e-commerce
✅ Construir un dashboard de ROAS con datos de la API
✅ Automatizar la gestión de negative keywords programáticamente
✅ Implementar auditoría técnica de cuentas Google Ads
❌ Definir estrategia de pujas (es trabajo del PPC strategist, no del ingeniero)
❌ Crear copys de anuncios (creativo, no técnico)
❌ Tomar decisiones de presupuesto (decisión de negocio)
```

> Para tracking de conversiones browser/server-side (GTM, GA4, Meta CAPI),
> cargar `Skill("tracking-measurement")` en su lugar o en paralelo.

## Conceptos clave

- **ROAS** (Return on Ad Spend): `valor_conversiones / costo`. Métrica primaria de rentabilidad de campañas.
- **Micros**: Google Ads almacena costos en micros (millonésimas). `cost_micros / 1_000_000` = valor real.
- **GAQL**: Google Ads Query Language. SQL-like para consultar la API. Recursos, segmentos y métricas tienen nombres específicos.
- **MCC** (Manager Customer Center): cuenta paraguas que agrupa sub-cuentas. El `login_customer_id` debe apuntar al MCC.
- **Search Term View**: recurso de la API que expone los términos exactos que activaron los anuncios — diferente a las keywords configuradas.

## Reglas obligatorias

### Regla 1 — Siempre dividir `cost_micros` entre 1,000,000

Google Ads devuelve todos los costos en micros. Operar con micros produce
cifras de ROAS, CPA y presupuesto completamente erróneas.

```python
# MAL — opera con micros directamente
roas = conversiones_valor / row.metrics.cost_micros

# BIEN — convierte primero
costo = row.metrics.cost_micros / 1_000_000
roas = conversiones_valor / costo if costo > 0 else 0
```

**Verificación**: El costo de una campaña típica en MXN debe estar en
el rango de decenas a miles, nunca en millones.

### Regla 2 — Manejar `GoogleAdsException` siempre

La API devuelve errores granulares por campo y operación. Un `except Exception`
silencia información crítica (quota excedida, permiso denegado, ID inválido).

```python
from google.ads.googleads.errors import GoogleAdsException

try:
    response = ga_service.search(customer_id=customer_id, query=query)
except GoogleAdsException as ex:
    for error in ex.failure.errors:
        logger.error(
            "Error Google Ads [%s]: %s",
            error.error_code,
            error.message,
        )
    raise
```

**Verificación**: Los logs deben mostrar `error_code` y `message` específicos,
no solo el texto genérico de la excepción.

### Regla 3 — `customer_id` sin guiones

La Google Ads API rechaza el formato `123-456-7890`. El formato correcto es
la cadena de dígitos sin guiones: `"1234567890"`.

```python
# MAL
customer_id = "123-456-7890"

# BIEN
customer_id = "1234567890"
# O limpiar al recibir del usuario:
customer_id = customer_id.replace("-", "")
```

**Verificación**: Un `INVALID_CUSTOMER_ID` en los logs indica este problema.

### Regla 4 — Nunca hardcodear credenciales de la API

`developer_token`, `refresh_token` y `client_secret` son credenciales de
producción. Van en variables de entorno o en un gestor de secretos.

```python
# MAL
client = GoogleAdsClient.load_from_dict({
    "developer_token": "ABcDeFgHiJkLmNoPq",
    "refresh_token": "1//0gXyz...",
})

# BIEN
client = GoogleAdsClient.load_from_dict({
    "developer_token": settings.GOOGLE_ADS_DEVELOPER_TOKEN,
    "client_id": settings.GOOGLE_ADS_CLIENT_ID,
    "client_secret": settings.GOOGLE_ADS_CLIENT_SECRET,
    "refresh_token": settings.GOOGLE_ADS_REFRESH_TOKEN,
    "login_customer_id": settings.GOOGLE_ADS_MCC_ID,
})
```

### Regla 5 — Cachear respuestas de la API al menos 1 hora

La API tiene quota diaria limitada (1,000 operaciones/día en nivel básico).
Llamar la API en cada request HTTP agota la quota en minutos con tráfico real.

```python
from functools import lru_cache
import time

_cache: dict = {}
_TTL = 3600  # 1 hora

def obtener_con_cache(clave: str, fn_obtener):
    """Caché simple con TTL para respuestas de Google Ads API."""
    ahora = time.time()
    if clave in _cache and (ahora - _cache[clave]["ts"]) < _TTL:
        return _cache[clave]["data"]
    datos = fn_obtener()
    _cache[clave] = {"data": datos, "ts": ahora}
    return datos
```

## Google Ads API — cliente Python

```python
from google.ads.googleads.client import GoogleAdsClient
from google.ads.googleads.errors import GoogleAdsException

client = GoogleAdsClient.load_from_dict({
    "developer_token": settings.GOOGLE_ADS_DEVELOPER_TOKEN,
    "client_id": settings.GOOGLE_ADS_CLIENT_ID,
    "client_secret": settings.GOOGLE_ADS_CLIENT_SECRET,
    "refresh_token": settings.GOOGLE_ADS_REFRESH_TOKEN,
    "login_customer_id": settings.GOOGLE_ADS_MCC_ID,
})

def obtener_rendimiento_campanas(customer_id: str, dias: int = 30) -> list[dict]:
    """Obtiene métricas de rendimiento de campañas de los últimos N días."""
    ga_service = client.get_service("GoogleAdsService")

    query = f"""
        SELECT
            campaign.id,
            campaign.name,
            campaign.status,
            metrics.impressions,
            metrics.clicks,
            metrics.conversions,
            metrics.cost_micros,
            metrics.conversions_value
        FROM campaign
        WHERE segments.date DURING LAST_{dias}_DAYS
            AND campaign.status = 'ENABLED'
        ORDER BY metrics.cost_micros DESC
    """

    try:
        response = ga_service.search(customer_id=customer_id, query=query)
        resultados = []
        for row in response:
            costo = row.metrics.cost_micros / 1_000_000
            roas = (row.metrics.conversions_value / costo) if costo > 0 else 0

            resultados.append({
                "id": str(row.campaign.id),
                "nombre": row.campaign.name,
                "impresiones": row.metrics.impressions,
                "clicks": row.metrics.clicks,
                "conversiones": row.metrics.conversions,
                "costo": round(costo, 2),
                "roas": round(roas, 2),
            })
        return resultados

    except GoogleAdsException as ex:
        for error in ex.failure.errors:
            raise ValueError(f"Error Google Ads: {error.message}") from ex
```

## Análisis de search terms para detección de desperdicio

Consultar la implementación completa de `analizar_search_terms` en
[recursos/auditoria-tracking.md](recursos/auditoria-tracking.md).

Patrón de detección de desperdicio: si `costo > 50` y `conversiones == 0`,
el término es candidato a negative keyword. Analizar bigramas y trigramas
(n=2 y n=3) sobre el vocabulario de search terms para encontrar patrones
de términos irrelevantes frecuentes.

## Detección de discrepancias entre plataformas

Consultar la función `ejecutar_auditoria_completa` en
[recursos/auditoria-tracking.md](recursos/auditoria-tracking.md).

Umbral aceptable: discrepancia < 15% entre Google Ads y GA4.
Causas frecuentes: ventanas de conversión distintas, modelos de atribución
diferentes (last-click vs data-driven), o conversiones offline no importadas.

## Anti-patrones críticos

| Anti-patrón | Problema | Solución |
|-------------|----------|----------|
| Operar con `cost_micros` sin convertir | ROAS y CPA erróneos por factor 1,000,000 | Dividir siempre entre `1_000_000` |
| `customer_id` con guiones `123-456-7890` | `INVALID_CUSTOMER_ID` de la API | `customer_id.replace("-", "")` |
| Llamar la API en cada request HTTP | Quota agotada en minutos | Caché con TTL de 1 hora mínimo |
| Credenciales en código fuente | Exposición en repositorio | Variables de entorno obligatorias |
| Filtrar sin considerar `campaign.status` | `PAUSED`/`REMOVED` contaminan reportes activos | Filtro explícito por status en GAQL |
| Usar `login_customer_id` de sub-cuenta en MCC | Accede a cuenta incorrecta o da error de permisos | Siempre el ID del MCC para cuentas administradas |

## Checklist de verificación

- [ ] `cost_micros` se divide entre `1_000_000` antes de cualquier operación aritmética
- [ ] `GoogleAdsException` capturada con iteración sobre `ex.failure.errors`
- [ ] `customer_id` sin guiones antes de llamar a la API
- [ ] Credenciales en variables de entorno (`settings.*`), no hardcodeadas
- [ ] Respuestas de la API con caché de al menos 1 hora
- [ ] Filtro de `campaign.status` en todas las queries de campañas activas
- [ ] `search_stream` usado para queries que puedan devolver >10,000 filas

## Referencias a recursos

| Tema | Archivo |
|------|---------|
| Google Ads API: autenticación, quota, GAQL avanzado, batch operations | [recursos/google-ads-api.md](recursos/google-ads-api.md) |
| Auditoría técnica: checklist de cuenta, métricas de calidad, reporte Python | [recursos/auditoria-tracking.md](recursos/auditoria-tracking.md) |

## Gotchas / Errores comunes no obvios

**La GAQL query con `WHERE segments.date DURING LAST_30_DAYS` incluye el día actual con datos parciales, inflando el conteo de conversiones del período**: si se corre el reporte a las 10 AM, el día de hoy tiene solo 10 horas de datos pero se incluye en el agregado de 30 días como si fuera un día completo. Para comparativas de períodos (mes actual vs mes anterior), esto produce distorsión. Causa: `LAST_N_DAYS` incluye el día actual con datos hasta el momento de la query. Fix: usar `BETWEEN YYYY-MM-DD AND YYYY-MM-DD` con fechas explícitas excluyendo el día actual, calculadas en Python: `fecha_fin = datetime.today() - timedelta(days=1)`.

**El `login_customer_id` correcto para MCC es el ID de la cuenta administradora, pero las queries retornan datos de TODAS las sub-cuentas mezclados si no se especifica `customer_id` por sub-cuenta**: si el MCC administra 50 cuentas y se hace una query de campañas sin filtrar por `customer_id`, la API devuelve campañas de todas las sub-cuentas con los mismos nombres, haciendo imposible distinguir a qué cuenta pertenece cada fila. Causa: el `login_customer_id` habilita el acceso al MCC pero `customer_id` en `search()` determina el scope. Fix: iterar sobre los sub-`customer_id` obtenidos de `CustomerService` y hacer una query por cuenta, o usar el campo `customer.id` en el `SELECT` de GAQL para incluir el ID de cuenta en cada fila del resultado.

**`search_stream` no puede usarse con queries que incluyen `ORDER BY` porque la API de streaming no garantiza orden y lanza `STREAMING_NOT_SUPPORTED_FOR_SORTED_QUERIES`**: al migrar de `search()` a `search_stream()` para manejar resultados grandes, las queries con `ORDER BY metrics.cost_micros DESC` fallan con un error poco descriptivo. Causa: el streaming de Google Ads API envía filas en el orden de procesamiento interno del servidor, no en el orden especificado. Fix: usar `search()` con paginación para queries ordenadas, y `search_stream()` solo para queries sin `ORDER BY`. Si se necesita orden + volumen grande, hacer el sort en Python después de recibir todos los resultados.

**Las conversiones de Google Ads tienen una ventana de atribución de 30-90 días, por lo que los datos de ROAS de los últimos 7 días siguen cambiando durante semanas después**: un reporte ejecutado el lunes puede mostrar ROAS de 2.1 para la semana anterior, pero el mismo período consultado 3 semanas después puede mostrar ROAS de 2.8 porque se atribuyeron conversiones tardías. Causa: la API devuelve los datos con las conversiones atribuidas hasta el momento de la query, no las del período. Fix: para reportes comparativos históricos, documentar la fecha de extracción y no comparar el período "de esta semana" contra períodos anteriores sin usar datos ya consolidados (más de 90 días atrás). Para dashboards en tiempo real, aceptar que los últimos 90 días tienen datos en evolución y mostrar la fecha de actualización.
