Quería saber qué páginas de mis proyectos se leen sin cargar en cada visita un script de más de 40 KB, sin mandar los datos a servidores de Estados Unidos y sin pelearme con la ley de protección de datos. De ahí salió Tempoo Analytics, una alternativa a Google Analytics que empecé en noviembre de 2025 y que hoy mide, entre otras webs, este blog.
En este artículo cuento cómo está construido: el script que se carga en la web, el servicio que recibe los eventos, dónde se guardan y qué datos se quedan por el camino.
La arquitectura de un vistazo
Todo pasa por un gateway hecho con YARP que reparte las peticiones según la ruta. Los eventos van a un servicio en Go, el panel y la API son .NET 10, y en local lo orquesto todo con .NET Aspire.
El reparto de responsabilidades es sencillo:
- ClickHouse guarda los eventos y responde a todas las consultas de métricas.
- Postgres guarda lo transaccional: usuarios, sitios, suscripciones, embudos y la bandeja de salida de mensajes.
- Redis hace de caché y de cola para los trabajos largos, como importar el histórico de Plausible o de GA4.
Un script de menos de 2 KB
El script es un único archivo sin dependencias que compilo con esbuild. Minificado pesa 2.937 bytes y, comprimido con brotli, se descargan 1.115. Para instalarlo basta con una línea en el <head>:
<script defer src="https://cdn.tempooanalytics.eu/analytics.js" data-site="TU_SITE_ID" data-key="TU_WRITE_KEY"></script>Al cargar, comprueba unas cuantas cosas antes de medir nada: que no se haya cargado ya, que el navegador no tenga activado Do Not Track y que la página no sea localhost ni un archivo local. Si todo cuadra, registra la página vista y se engancha a history.pushState y a popstate para seguir la navegación en aplicaciones de una sola página.
Para enviar los eventos usa navigator.sendBeacon, que no bloquea la página y sigue funcionando cuando el usuario la cierra. Si el navegador no lo soporta, cae a fetch con keepalive: true:
function send(payload) { const body = new Blob([JSON.stringify(payload)], { type: "application/json" }); if (navigator.sendBeacon && navigator.sendBeacon(`${apiUrl}?key=${writeKey}`, body)) return; fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": writeKey }, body: JSON.stringify(payload), keepalive: true, });}Las conversiones se miden sin escribir JavaScript. Un clic en cualquier elemento con data-tempoo-goal envía un evento, y los atributos data-tempoo-goal-* viajan como propiedades:
<a href="/precios" data-tempoo-goal="ver-precios" data-tempoo-goal-plan="freelance">Ver precios</a>La ingesta en Go: responder rápido y escribir por lotes
El endpoint POST /analytics/events está escrito en Go con Gin. Su trabajo es aceptar el evento lo antes posible y devolver un 202. Antes de eso hace lo imprescindible:
- Busca la configuración del sitio (plan, cuota, orígenes permitidos). La consulta va a Postgres y el resultado se guarda cinco minutos en Redis, así que casi nunca toca la base de datos.
- Valida la clave de escritura y, si no viene, comprueba que el origen de la petición esté en la lista del sitio.
- Enriquece el evento: país, ciudad y ASN con las bases de MaxMind GeoLite2, navegador y sistema operativo a partir del user agent, y los parámetros UTM de la URL.
- Clasifica el tráfico en humano, crawler o bot de IA (GPTBot, ClaudeBot, Perplexity y compañía). Los bots no se descartan: se etiquetan, y el panel puede enseñarlos aparte.
Lo que no hace es escribir en ClickHouse en esa misma petición. ClickHouse rinde mucho mejor con inserciones grandes y poco frecuentes que con miles de inserciones de una fila, así que el evento entra en un canal con capacidad para 100.000 eventos. Tres goroutines lo vacían y escriben en lotes de hasta 5.000 eventos, o cada cinco segundos si el lote no se llena antes:
func (w *Worker) run(ctx context.Context) { batch := make([]Event, 0, 5000) ticker := time.NewTicker(5 * time.Second) defer ticker.Stop()
for { select { case e := <-w.events: batch = append(batch, e) if len(batch) == cap(batch) { w.flush(ctx, batch) batch = batch[:0] } case <-ticker.C: if len(batch) > 0 { w.flush(ctx, batch) batch = batch[:0] } case <-ctx.Done(): w.flush(context.Background(), batch) return } }}Es una versión simplificada del worker real, pero la idea es esa. Al apagar el servicio se vacía lo que quede en el canal. La contrapartida es que la cola vive en memoria: si el proceso se cae de golpe, se pierden los eventos de los últimos segundos que aún no se habían escrito.
ClickHouse para los números, Postgres para lo demás
Al principio los eventos también vivían en Postgres, con una tabla de agregados aparte. En marzo de 2026 los moví a ClickHouse, y en el mismo cambio la ingesta pasó de .NET a Go.
La tabla events es un MergeTree particionado por mes y ordenado por sitio y fecha, que es justo por donde filtran todas las consultas del panel. Esta es una versión reducida del esquema:
CREATE TABLE events( site_id String, date DateTime, event_name LowCardinality(String), user_hash FixedString(64), visitor_id String, session_id UUID, path String, browser LowCardinality(String), country_code LowCardinality(String), traffic_type Enum8('human' = 1, 'bot_ia' = 2, 'crawler' = 3, 'vpn_proxy' = 4) -- más columnas: UTM, referrer, dispositivo...)ENGINE = MergeTreePARTITION BY toYYYYMM(date)ORDER BY (site_id, date, event_name, user_hash);Encima hay vistas materializadas que van rellenando tablas de estadísticas por hora y por dimensión (navegador, país, página, referrer). Los embudos se resuelven con windowFunnel, una función de ClickHouse que, para cada sesión, calcula hasta qué paso del embudo llegó en una ventana de tiempo. Hacer eso en SQL estándar requiere bastantes más líneas.
Datos casi en tiempo real
Con el buffer de la ingesta, un evento tarda como mucho unos cinco segundos en estar en ClickHouse. El panel pide los datos cada diez segundos, así que lo que pasa en la web aparece en pantalla en cuestión de segundos. No hay websockets: para un panel de analítica, consultar cada pocos segundos me resulta más simple de mantener y suficiente.
Qué datos guarda y cuáles no
El script no usa cookies y no guardo la IP completa. Con la IP y el user agent calculo un hash SHA-256 que cambia cada mes, y lo uso para contar visitantes únicos sin saber quiénes son. Además, el script genera dos identificadores aleatorios: uno de sesión en sessionStorage, que desaparece al cerrar la pestaña, y uno de visitante en localStorage. Si el navegador tiene activado Do Not Track, no se envía nada. Los servidores están en la Unión Europea, en OVHcloud (Francia) y Contabo (Alemania).
Lo del banner de consentimiento merece una explicación. No usar cookies no significa que nunca haga falta pedir permiso: la normativa europea (la directiva ePrivacy) habla de guardar o leer información en el dispositivo del usuario, y localStorage entra en esa categoría. Hay quien considera que un identificador de visitante para analítica propia está exento y quien no. En este blog he optado por lo prudente: el script de Tempoo solo se carga si aceptas en el aviso, y si lo rechazas borro los dos identificadores.
Lo que hay hasta ahora
Además de lo que cuento arriba, Tempoo ya tiene enlaces públicos de solo lectura para compartir el panel con clientes, importación del histórico de Plausible y pagos con Polar. Lo desarrollo yo solo, en cambios lo bastante pequeños como para que cada uno quepa en un PR.
Si quieres probarlo en tu web, tienes 15 días gratis en tempooanalytics.eu.
Comentarios
Aún no hay comentarios. ¡Sé el primero!
Deja un comentario