Tagging y Obfuscación GA4
Cómo un evento GA4 viaja desde el browser del usuario hasta el contenedor sGTM, evitando ad-blockers en cada paso.
El problema que resuelve
Los ad-blockers (Brave, uBlock Origin, AdGuard) bloquean peticiones a dominios conocidos de Google:
||googletagmanager.com^
||google-analytics.com^
||analytics.google.com^
Si el sitio web hace una request directa a www.googletagmanager.com o www.google-analytics.com/g/collect, esa request es bloqueada antes de salir del browser.
Crimoo resuelve esto en dos capas:
- Custom Loader: sirve el script de GTM desde el dominio propio del cliente
- GA4 Bypass: intercepta y re-enruta los hits de GA4 a través del proxy
Flujo completo
crimoo.js — el script compuesto
/crimoo.js es el punto de entrada. El proxy lo genera dinámicamente con CustomLoaderService.generateFullScript() y tiene dos partes:
Parte 1: GTM Bootstrap
Un script estándar de GTM modificado para apuntar al dominio propio en vez de www.googletagmanager.com:
// GTM estándar de Google apuntaría a:
j.src = 'https://www.googletagmanager.com/gtm.js?id=GTM-NKDCS9NQ';
// crimoo.js apunta a:
j.src = 'https://rc-XXXX.crimoo.com/assets/ce7a90';
// El proxy añade ?id=GTM-NKDCS9NQ server-side antes de ir a Google
El browser solo ve el dominio de Crimoo — nunca googletagmanager.com.
Parte 2: Identity Script (obfuscado)
Parchea las tres APIs de red del browser para interceptar eventos GA4:
// Intercepta fetch
window.fetch = function(url, opts) {
url = rp(url); // rp() reemplaza /g/collect con path obfuscado
url = ap(url); // ap() añade _iou y _ious + obfusca query params
return originalFetch(url, opts);
}
// Intercepta XMLHttpRequest
XMLHttpRequest.prototype.open = function(method, url) {
return originalOpen(method, rp(ap(url)));
}
// Intercepta sendBeacon
navigator.sendBeacon = function(url, body) {
return originalSendBeacon(rp(ap(url)), body);
}
Obfuscación en tres niveles
Nivel 1 — Path obfuscation (rp())
La función rp() reemplaza /g/collect con un path aleatorio del array _bp (bypass paths):
var _bp = ["/opt/927d", "/data/e3a1", "/metrics/f9c2"]; // configurado en ObfuscationConfig
var _gc = '/' + 'g' + '/' + 'co' + 'llect'; // /g/collect en partes (anti-static-analysis)
function rp(url) {
if (!url || !_bp.length) return url;
var i = url.indexOf(_gc);
// Solo intercepta si la URL contiene /g/collect Y el dominio de Crimoo
if (i === -1 || url.indexOf(DOMAIN) === -1) return url;
// Reemplaza /g/collect con path aleatorio del array
return url.substring(0, i) + _bp[Math.floor(Math.random() * _bp.length)] + url.substring(i + _gc.length);
}
Por qué importa: los ad-blockers también bloquean /g/collect como path conocido de GA4. Con un path aleatorio como /opt/927d/XXXX, la URL no matchea ninguna lista negra.
El path también incluye el container ID (3mxuoufac355d49acdf001) para que el proxy sepa a qué GTM pertenece el evento.
Nivel 2 — Query param obfuscation (eq())
Los query params de GA4 son reconocibles (v=2&en=page_view&tid=G-...). La función eq() los codifica en base64url bajo el param _hn:
var _qn = '_hn'; // query param name configurado en ObfuscationConfig
function eq(url) {
var qi = url.indexOf('?');
if (qi === -1) return url;
var base = url.substring(0, qi);
var qs = url.substring(qi + 1); // "v=2&en=page_view&tid=G-..."
var enc = btoa(qs).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return base + '?' + _qn + '=' + enc;
// resultado: /opt/927d/XXXX?_hn=djI9Mi....
}
Request final que ve la red:
GET /opt/927d/3mxuoufac355d49acdf001?_hn=djI9Mn...
Un ad-blocker que inspeccione el tráfico no puede determinar que es un evento GA4.
Nivel 3 — API path obfuscation (ObfuscatedPathRewriter)
Además de los hits de GA4, los paths de API interna también se ofuscan para evitar detección por nombre:
| Path ofuscado | Path real | Uso |
|---|---|---|
/svc/a3f2/{containerId} | /api/capture-config/{containerId} | Config de captura pública |
/dat/b7e1 | /api/i | Identity resolution |
Estos paths se configuran en ObfuscationConfig como captureConfigPath e identityApiPath. A diferencia de los bypass paths de GA4, estos se reescriben en el proxy y se redirigen al management app (:8000) via ForwardingHandler, no al container Docker.
Decodificación en el proxy (Ga4BypassService)
El proxy recibe la request obfuscada y la restaura antes de publicar a Pub/Sub:
// 1. Restaurar path: /opt/927d/XXXX → /g/collect
String restoredPath = ga4BypassService.restorePath(modifiedPath, config);
// 2. Decodificar query params: _hn=base64 → v=2&en=page_view&tid=G-...
String realQuery = ga4BypassService.deobfuscateQueryParams(query, config);
El TaggingEvent que llega a gtm-fabric (y luego al sGTM) tiene el path y params originales de GA4 — el sGTM recibe exactamente lo mismo que recibiría de Google directamente.
Preview con ofuscación
Cuando un browser está en modo preview de GTM, los requests siguen pasando por el identity script (rp + eq + ap). La URL que llega al proxy está ofuscada igual que en producción.
La diferencia es que el proxy de-ofusca primero y luego detecta el modo preview. El container recibe el request limpio, como si viniera directo de Google:
Puntos clave:
- La de-ofuscación ocurre antes de la decisión sync/async — el path y params ya están restaurados cuando se evalúa si es preview
- El container nunca ve paths ofuscados — recibe
/g/collect?v=2&...como si el request viniera de Google directamente - La única diferencia con el flujo normal es que preview usa
proxyDirect(sync, espera response) en vez deproxyAsync(202 + Pub/Sub)
ObfuscationConfig — dónde se configura
Guardado en gtm_custom_loaders.obfuscation_config (JSONB en PostgreSQL):
{
"cookieName": "_iou",
"globalObject": "_wd",
"apiPath": "/opt/927d",
"debugKey": "dbg_mlta9i9y",
"bypassPaths": ["/assets/82c091", "/resources/a81c31", "/chunks/97d79d", "/bundles/1c9acf", "/content/586160"],
"gtagBypassPath": "/assets/ce7a90",
"queryParam": "_hn",
"captureConfigPath": "/svc/a3f2",
"identityApiPath": "/dat/b7e1",
"varNames": {
"CU": "hz",
"CS": "iw",
"DOMAIN": "kwy",
"CID": "djg",
"CONFIG_URL": "yph",
"API_URL": "azh"
}
}
| Campo | Uso |
|---|---|
cookieName | Nombre base de las cookies de identidad (device: _iou, session: _ious) |
globalObject | Nombre del objeto global JS que almacena el estado del identity script |
apiPath | Path base para los endpoints de API en el identity script |
debugKey | Key para activar el modo debug desde query params o localStorage |
bypassPaths | Paths alternativos para /g/collect. Se elige uno aleatoriamente en cada hit |
gtagBypassPath | Path que sirve el script de GTM (/gtm.js) desde el dominio propio |
queryParam | Nombre del param que envuelve los query params codificados en Base64 |
captureConfigPath | Path ofuscado para /api/capture-config (prefix match) |
identityApiPath | Path ofuscado para /api/i (exact match) |
varNames | Mapa de nombres ofuscados de variables JS (CU, CS, DOMAIN, etc.) |
El gtagBypassPath (/assets/ce7a90)
Este path tiene una sola responsabilidad: servir el script de GTM desde el dominio propio.
Browser: GET https://rc-XXXX.crimoo.com/assets/ce7a90
Proxy: GET https://www.googletagmanager.com/gtm.js?id=GTM-NKDCS9NQ
El ?id=GTM-NKDCS9NQ es añadido server-side en ProxyHandler.handleCustomLoader():
if (customLoaderService.isGtagBypassPath(path, container.getObfuscationConfig())) {
String gtmLoaderPath = "/gtm.js?id=" + container.getWebGtmId();
return proxyToGoogle(request, response, gtmLoaderPath);
}
El browser solo ve /assets/ce7a90 — nunca googletagmanager.com. Google responde con el script GTM estándar, que el proxy devuelve tal cual al browser.
Identity params: _iou y _ious
Además de obfuscar los params de GA4, el identity script añade dos params propios:
| Param | Cookie | TTL | Descripción |
|---|---|---|---|
_iou | _iou | 730 días | Device ID único (persistente) |
_ious | _ious | 30 min | Session ID (se renueva por sesión) |
Estos params permiten a Crimoo hacer identity resolution y linkear eventos GA4 con contactos del CRM, incluso sin login.
Request GA4 real que llega al sGTM:
POST /g/collect?v=2&tid=G-FSDYKDL52D&en=page_view&cid=1798635578&..._iou=d1051-9e8f...&_ious=s0072-fbff...
Por qué el sGTM no modifica el script
El flujo original intentaba que el sGTM sirviera el script GTM (en vez de Google), bajo la premisa de que el sGTM lo modificaría para apuntar a sí mismo como servidor. Sin embargo:
- El sGTM solo puede servir scripts para web containers explícitamente vinculados a él en el GTM UI
- El identity script ya maneja todo el routing de eventos independientemente
- Si el sGTM reinicia y no puede descargar su configuración de Google, devuelve 400 — rompiendo la carga de GTM en el browser
Por eso gtagBypassPath va directamente a Google. El sGTM se dedica únicamente a procesar los hits que le llegan vía Pub/Sub.