---
name: tracking-measurement
description: >
  Implementación técnica de sistemas de medición web: GTM (dataLayer, triggers,
  variables, tags), GA4 (eventos, e-commerce, dimensiones personalizadas), Meta
  CAPI server-side con deduplicación, Enhanced Conversions y Consent Mode v2.
  Cargar cuando se implemente conversion tracking, se configure GTM, se integre
  GA4 o Meta Pixel, se construya un endpoint server-side para CAPI, o se
  implemente cumplimiento de privacidad GDPR/CCPA en tracking.
version: "1.0.0"
herramientasPermitidas: [Read, Write]
evolvable: true  # default para skill estandar
exclusiones:
  - "No cargar para análisis de datos de conversiones ya capturados (reportes de ROAS, atribución multi-touch) — para análisis de datos de paid media cargar `paid-media-tracking`."
  - "No cargar para instrumentación de aplicaciones backend con OpenTelemetry o métricas de negocio en Prometheus — para observabilidad de sistemas cargar `monitoring-alertas`."
  - "No cargar para implementar sistemas de analytics propietarios o data warehouses (BigQuery, Snowflake) sin componente de tracking web — para pipelines de datos cargar el skill de cloud correspondiente."
  - "No cargar para scraping o extracción de datos de plataformas publicitarias externas — para acceso a APIs de Google Ads o Meta Business cargar `paid-media-tracking`."
---
# tracking-measurement — Sistemas de medición web

## Cuándo NO cargar

- La tarea es analizar datos de conversiones ya capturados (ROAS, atribución): cargar `paid-media-tracking`.
- La tarea es instrumentación de backend con OpenTelemetry o Prometheus: cargar `monitoring-alertas`.
- La tarea es construir un data warehouse o pipeline de datos sin componente de tracking web: cargar el skill de cloud correspondiente.
- La tarea es acceder a APIs de Google Ads o Meta Business para reportes: cargar `paid-media-tracking`.

## Cuándo cargar este skill

- Configurar GTM: workspace, triggers, variables, tags
- Implementar eventos GA4 (e-commerce, custom dimensions, Measurement Protocol)
- Construir endpoint server-side para Meta Conversions API (CAPI)
- Implementar Enhanced Conversions de Google Ads
- Añadir Consent Mode v2 (GDPR/CCPA) a una aplicación web
- Integrar CMP (Cookiebot, OneTrust, Usercentrics) con GTM
- Depurar discrepancias entre datos de plataformas y datos reales

## Arquitectura de medición moderna

```
Navegador                      Servidor                    Plataformas
─────────                      ────────                    ──────────
GTM Container                  Server-side GTM             Google Analytics 4
  └─ dataLayer.push()    ──►   (Cloud Run / App Engine)   Meta Ads Manager
  └─ GA4 tag             ──►   └─ GA4 Measurement API     Google Ads
  └─ Meta Pixel tag      ──►   └─ Meta CAPI               Microsoft Ads
  └─ Consent Mode        ──►   └─ Hash PII antes de enviar
```

El tracking browser-only pierde 40-60% de eventos en Safari/iOS por ITP.
El server-side tagging recupera esos eventos usando datos first-party.

## Reglas obligatorias

### Regla 1 — Limpiar ecommerce antes de cada evento

Siempre ejecutar `dataLayer.push({ ecommerce: null })` antes de cualquier
evento e-commerce. Sin esto, GTM mezcla datos de eventos anteriores.

**Verificación**: En Tag Assistant, cada evento e-commerce debe tener
`ecommerce: null` inmediatamente antes en el historial del dataLayer.

### Regla 2 — Hashear PII antes de enviar a plataformas externas

Email y teléfono NUNCA se envían en texto plano. Siempre SHA-256 normalizado:
email en minúsculas sin espacios, teléfono en formato E.164.

```python
import hashlib

def hash_pii(valor: str) -> str:
    """Normaliza y hashea PII para plataformas externas (SHA-256)."""
    return hashlib.sha256(valor.strip().lower().encode()).hexdigest()
```

**Verificación**: En los payloads de red (DevTools > Network), los campos
`em`, `ph`, `hashed_email` deben ser cadenas de 64 caracteres hexadecimales.

### Regla 3 — Deduplicación con event_id único

Cuando el mismo evento se envía desde browser (pixel) Y desde servidor (CAPI
o Measurement Protocol), AMBOS deben incluir el mismo `event_id`. Sin esto,
GA4 y Meta cuentan el evento dos veces.

```javascript
// Generar event_id antes de disparar el evento browser
const eventId = `purchase_${transactionId}_${Date.now()}`;
// Este mismo eventId se pasa al servidor para el envío CAPI
```

**Verificación**: En Meta Events Manager > Test Events, el campo "Match
type" debe mostrar "Deduplicated" para eventos con browser + server.

### Regla 4 — Consent Mode: default denied, actualizar al consentir

Inicializar Consent Mode con todo denegado ANTES de cargar GTM. Actualizar
solo después de que el usuario otorgue consentimiento explícito.

```javascript
// Debe ejecutarse ANTES del snippet de GTM
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
  analytics_storage: 'denied',
  ad_storage: 'denied',
  ad_user_data: 'denied',
  ad_personalization: 'denied',
  wait_for_update: 500,
});
```

**Verificación**: En Chrome DevTools > Application > Cookies, no deben
existir cookies `_ga`, `_fbp` antes de que el usuario acepte.

### Regla 5 — transaction_id único por compra

Un `transaction_id` duplicado en GA4 causa que la compra no se cuente
(deduplicación silenciosa). Usar el ID de orden del sistema, nunca
generado en frontend.

**Verificación**: En GA4 DebugView, el evento `purchase` debe aparecer
con el mismo `transaction_id` que la orden en la base de datos.

## dataLayer schema para e-commerce (GA4 Enhanced Ecommerce)

```javascript
window.dataLayer = window.dataLayer || [];

// Vista de producto
dataLayer.push({ ecommerce: null });
dataLayer.push({
  event: 'view_item',
  ecommerce: {
    currency: 'MXN',
    value: 299.00,
    items: [{
      item_id: 'SKU-001',
      item_name: 'Producto Premium',
      item_category: 'Software',
      price: 299.00,
      quantity: 1,
    }]
  }
});

// Compra completada
const eventId = `purchase_${transactionId}_${Date.now()}`;
dataLayer.push({ ecommerce: null });
dataLayer.push({
  event: 'purchase',
  event_id: eventId,           // Para deduplicación con Meta CAPI
  ecommerce: {
    transaction_id: 'ORD-2026-001',  // Único — del sistema backend
    value: 299.00,
    tax: 47.84,
    currency: 'MXN',
    items: [{
      item_id: 'SKU-001',
      item_name: 'Producto Premium',
      price: 299.00,
      quantity: 1,
    }]
  },
  user_data: {                 // Para Enhanced Conversions (GTM hashea esto)
    email_address: 'usuario@ejemplo.com',
    phone_number: '+525512345678',
  }
});
```

## GA4 via Measurement Protocol (server-side)

```python
import httpx
from app.core.config import settings

async def enviar_evento_ga4(
    client_id: str,
    evento: str,
    parametros: dict,
    session_id: str | None = None,
) -> None:
    """Envía evento a GA4 desde el servidor usando Measurement Protocol."""
    payload = {
        "client_id": client_id,
        "events": [{
            "name": evento,
            "params": {
                "session_id": session_id,
                "engagement_time_msec": "100",
                **parametros,
            }
        }]
    }
    async with httpx.AsyncClient() as client:
        await client.post(
            "https://www.google-analytics.com/mp/collect",
            params={
                "measurement_id": settings.GA4_MEASUREMENT_ID,
                "api_secret": settings.GA4_API_SECRET,  # Solo en servidor
            },
            json=payload,
            timeout=5.0,
        )
```

El `client_id` viene de la cookie `_ga`: formato `GA1.1.XXXXXXXXXX.XXXXXXXXXX`,
los dos últimos segmentos separados por punto.

## Anti-patrones críticos

| Anti-patrón | Problema | Solución |
|-------------|----------|----------|
| Email en texto plano en CAPI | Violación GDPR/Meta ToS | SHA-256 normalizado |
| `transaction_id` generado en frontend | Duplicados por recargas | Usar ID de orden del backend |
| Sin `ecommerce: null` previo | Datos de evento anterior contaminan | Siempre limpiar antes |
| `api_secret` de GA4 en frontend | Exposición de credenciales | Solo en variables de servidor |
| Pixel browser como única fuente | 40-60% pérdida en iOS | Agregar server-side CAPI |
| Consent Mode sin inicializar | Cookies antes del banner | Default denied antes de GTM |

## Referencias a recursos

| Tema | Archivo |
|------|---------|
| GTM: dataLayer avanzado, triggers, variables, workspace, server-side | [recursos/gtm-datalayer.md](recursos/gtm-datalayer.md) |
| Meta CAPI: endpoint completo, deduplicación, hashing PII, EMQ | [recursos/meta-capi.md](recursos/meta-capi.md) |
| Consent Mode v2, GDPR, CCPA, integración con CMPs | [recursos/consent-mode.md](recursos/consent-mode.md) |

---

## Gotchas / Errores comunes no obvios

**GTM dispara el tag de `purchase` dos veces en SPAs (React, Angular) porque el trigger "All Pages" se activa tanto en la carga inicial como en la navegación history-pushState, y el dataLayer push de compra queda en memoria entre rutas**: en una tienda Angular, el usuario completa la compra en `/checkout/success`, el evento `purchase` se envía correctamente. Pero si el usuario regresa a `/` usando el botón atrás y luego navega de nuevo a `/checkout/success` sin recargar, GTM vuelve a disparar el tag porque el dataLayer aún contiene el objeto `ecommerce` de la compra anterior. Causa: GTM persiste el dataLayer en memoria durante toda la sesión de la SPA; sin limpiar `{ ecommerce: null }` al navegar, el estado de eventos anteriores contamina rutas subsecuentes. Fix: en el router de la SPA, ejecutar `dataLayer.push({ ecommerce: null })` en cada cambio de ruta (Angular Router: `NavigationEnd` event), y usar triggers de GTM basados en evento personalizado (`purchase_completed`) en lugar de "All Pages" para eventos de conversión.

**Consent Mode v2 configurado con `wait_for_update: 500` bloquea el primer pageview de GA4 en dispositivos lentos donde el CMP tarda más de 500ms en renderizar, causando pérdida del 5-15% de pageviews en usuarios móviles con conexión 3G**: el banner de consentimiento en redes lentas tarda 800-1200ms en cargar el script del CMP. Durante ese tiempo, `wait_for_update: 500` expiró y GA4 disparó el pageview sin el estado de consentimiento correcto, lo que puede generar datos sin la cookie `_ga` y pageviews no atribuibles. Causa: `wait_for_update` es una espera máxima en milisegundos; si el CMP no llama a `gtag('consent', 'update', ...)` antes de ese límite, GA4 procede con el estado default. Fix: aumentar `wait_for_update` a 1500ms para móviles o implementar el CMP de forma que el script cargue de forma crítica (no diferida) para garantizar que actualiza el consentimiento antes del timeout.

**La deduplicación de Meta CAPI falla silenciosamente cuando el `event_id` del browser pixel y del server-side tienen formatos diferentes aunque semánticamente sean el mismo ID**: `event_id: "purchase_ORD-2026-001_1714500000000"` en el browser pixel y `event_id: "purchase_ord-2026-001_1714500000000"` (lowercase) en CAPI son tratados como eventos diferentes por Meta. Meta Events Manager muestra los dos como eventos separados sin deduplicar, duplicando las conversiones reportadas. Causa: Meta compara `event_id` como string exacto, case-sensitive. Fix: definir la generación del `event_id` en una sola función compartida (backend), enviarla al frontend como parte de la respuesta de creación de orden, y usar exactamente ese string en ambos lados. Documentar el formato como contrato: `purchase_{ORDER_ID_UPPERCASE}_{UNIX_TIMESTAMP_MS}`.

**El hashing SHA-256 de email para Meta CAPI falla en la deduplicación cuando el email viene con caracteres de espacio en blanco invisible (NBSP, U+00A0) que el usuario copió de un email de confirmación**: `hashlib.sha256("usuario@ejemplo.com".encode()).hexdigest()` y `hashlib.sha256("usuario@ejemplo.com\u00a0".encode()).hexdigest()` producen hashes completamente diferentes, y Meta no puede hacer match con el PII del usuario registrado. Causa: el método `.strip()` de Python elimina espacios ASCII (U+0020) pero no todos los caracteres de espacio Unicode como NBSP (U+00A0) o ZWSP (U+200B). Fix: normalizar el email con `unicodedata.normalize('NFKC', email).strip().lower()` antes del hash. La normalización NFKC convierte variantes Unicode de caracteres de espacio a sus equivalentes ASCII, garantizando consistencia del hash.
