Analítica Web “Zero-Tracking”: construye tu propio sistema en JS puro y Rust sin cookies ni identificadores
Inicia sesión para descargarConstruimos una analítica web zero-tracking con JavaScript puro y Rust: tracker ligero, backend con Axum, SQLite, dashboard mínimo y soporte para SPA sin cookies.
Contenido del tutorial ⌄
- 1. Qué vamos a construir y por qué este enfoque importa
- 2. La arquitectura: cómo fluye realmente un pageview
- 3. tracker.js: donde una visita se convierte en un evento
- 4. El backend en Rust: donde el evento deja de ser navegador y se vuelve dato
- 5. main.rs: cómo levantamos el sistema completo
- 6. Las demos: por qué /demo y /demo-spa vuelven verificable el tutorial
📊 Analítica web · JS puro · Rust · Privacidad
No todas las webs necesitan scripts pesados, perfiles de usuario ni una infraestructura de rastreo para entender qué está pasando. En este tutorial vamos a construir un sistema completo que registra pageviews reales, persiste eventos en SQLite, expone un dashboard mínimo y además demuestra cómo medir navegación tipo SPA sin recurrir a cookies ni técnicas invasivas.
La promesa aquí no es “hacer otro clon de analítica web”, sino algo más interesante: demostrar que muchas veces basta una arquitectura pequeña, clara y honesta para medir lo esencial. Vamos a levantar un tracker.js que envía eventos, un backend en Rust que los recibe por POST /collect, rutas de demostración que sí disparan métricas reales y un dashboard que nos deja validar todo el recorrido extremo a extremo.
Dicho de otra forma: este tutorial no gira alrededor de marketing ni de perseguir usuarios, sino de ingeniería. Queremos comprobar, con código ejecutable y pruebas concretas, que sí es posible obtener métricas útiles sin caer en identificadores persistentes, cookies de seguimiento o integraciones externas que terminan haciendo mucho más de lo que realmente necesitas.
El problema con mucha analítica web moderna no es solo técnico, sino también conceptual. Muy a menudo se parte de una pregunta razonable —“¿qué páginas se visitan y desde dónde llegan?”— y se termina desplegando una maquinaria mucho más agresiva de la necesaria: cookies, identificadores persistentes, scripts pesados, perfiles por sesión y una dependencia innecesaria de plataformas externas. Para muchos proyectos, eso no es precisión: es exceso.
Aquí vamos a tomar el camino contrario. En lugar de empezar por el rastreo, vamos a empezar por la utilidad. Queremos contar pageviews reales, registrar la ruta, conservar un referrer razonable, guardar algunos metadatos mínimos del navegador y poder consultar esa información desde un dashboard sencillo. Nada más. Esa restricción no empobrece el sistema: lo vuelve más claro, más ligero y bastante más honesto.
Muchas veces no necesitas saber quién es el usuario. Necesitas saber qué ocurrió, en qué ruta ocurrió y cómo validar que esa métrica sí representa una visita real.
1. Qué vamos a construir y por qué este enfoque importa
El sistema de este tutorial tendrá cinco piezas muy concretas. Primero, un tracker.js en JavaScript puro capaz de detectar la ruta actual y enviar un pageview. Después, un backend en Rust + Axum que expone un endpoint POST /collect para recibir esos eventos. Luego, una base SQLite donde persistiremos cada visita. Encima de eso, construiremos un dashboard mínimo para consultar top páginas, referrers, idiomas y visitas por día. Y por último, añadiremos dos rutas de demostración —/demo y /demo-spa— para comprobar que el flujo funciona de verdad.
Lo importante aquí es que no estamos montando una demo artificial que “parece” funcionar. Vamos a recorrer un circuito completo: navegador, envío del evento, validación en el backend, inserción en la base de datos y visualización final en el dashboard. Esa trazabilidad es lo que convierte este tutorial en una pieza seria: no solo enseña código, también enseña cómo verificar que el sistema realmente hace lo que promete.
Objetivo práctico del tutorial
Al terminar, podrás abrir una ruta como /demo, ver cómo se dispara un pageview, cambiar rutas en /demo-spa sin recargar la página y confirmar en /dashboard que esos eventos quedaron almacenados correctamente.
2. La arquitectura: cómo fluye realmente un pageview
Antes de abrir archivos o escribir código, necesitamos hacer algo más importante: entender el sistema como un flujo. Si no tienes claro cómo viaja un pageview desde el navegador hasta el dashboard, el código se vuelve ruido. Si lo tienes claro, cada línea tiene sentido.
Aquí no hay magia ni “cajas negras”. El sistema es deliberadamente simple, y eso es precisamente lo que lo hace poderoso. Cada componente tiene una responsabilidad muy concreta y todo el recorrido es trazable de extremo a extremo.
- El navegador detecta la ruta actual (incluyendo cambios en SPA).
-
tracker.jsconstruye un payload mínimo y lo envía al backend. -
El endpoint
POST /collectrecibe, valida y normaliza los datos. - El backend persiste el evento en SQLite.
- El dashboard consulta datos agregados y los muestra como métricas.
Este flujo tiene una propiedad clave: no depende de identidad. No hay cookies, no hay session IDs, no hay fingerprinting. Cada evento es independiente y se interpreta por su contexto: la ruta, el momento y algunos metadatos básicos del navegador.
A partir de aquí, vamos a recorrer el sistema en el mismo orden en que ocurren los eventos en la vida real. Primero entenderemos cómo el navegador decide enviar un pageview. Luego veremos cómo el backend lo procesa. Y finalmente, cómo esos datos terminan convertidos en métricas visibles.
3. tracker.js: donde una visita se convierte en un evento
Todo empieza en el navegador. Antes de que exista una fila en SQLite o una tabla en el dashboard, tiene que ocurrir algo mucho más básico: detectar que hay una página activa, construir un payload razonable y enviarlo al backend sin romper la experiencia del usuario. Esa es la responsabilidad exacta de tracker.js.
Aquí estamos tomando una decisión arquitectónica importante. En lugar de depender de un SDK pesado o de una librería externa llena de lógica opaca, vamos a usar un script pequeño en JavaScript puro. Su trabajo no es “vigilar usuarios”, sino reaccionar a eventos muy concretos del navegador y traducirlos a un pageview mínimo, entendible y verificable.
El flujo interno del script es más elegante de lo que parece a primera vista. Primero identifica la ruta actual con location.pathname + location.search. Después construye un objeto con metadatos básicos como referrer, resolución de pantalla, idioma, desfase horario, título del documento y timestamp del cliente. Luego decide cómo enviarlo: si el navegador soporta sendBeacon, aprovecha ese transporte; si no, cae a un fetch tradicional con keepalive.
Pero este archivo hace algo más interesante: también entiende el comportamiento de una SPA. En una aplicación tradicional, una carga de página dispara naturalmente un nuevo ciclo del navegador. En una SPA, en cambio, la URL puede cambiar sin recargar el documento. Por eso interceptamos pushState, replaceState y escuchamos popstate. Esa capa es la que permite que una navegación interna también termine convertida en una métrica visible.
Qué debe quedar claro en esta parte
tracker.js no “adivina” nada sobre el usuario. Solo observa la ruta actual, empaqueta contexto mínimo y lo envía al backend usando el transporte más apropiado disponible en el navegador.
También vale la pena notar un detalle de calidad muy útil para el tutorial: el script emite un evento de navegador llamado zt:pageview-sent. Eso nos permite construir demos que no solo envían el pageview, sino que además muestran una confirmación visible de que el envío ocurrió. En otras palabras, el propio frontend nos ayuda a depurar y a enseñar.
Archivo
src/static/tracker.js
Este archivo contiene el recolector del lado del cliente. Detecta rutas, evita duplicados obvios, construye el payload, selecciona el transporte y notifica el resultado para que podamos validar el flujo completo desde el navegador.
Con este contexto ya sí tiene sentido abrir el archivo. Lo que vas a ver a continuación no es solo “un script que manda JSON”, sino la primera pieza real del sistema: la que convierte una visita en un evento medible.
(function () {
var currentScript = document.currentScript;
var endpoint = (currentScript && currentScript.dataset && currentScript.dataset.endpoint) || "/collect";
var debug = !!(currentScript && currentScript.dataset && currentScript.dataset.debug === "true");
var lastSentPath = null;
function currentPath() {
return location.pathname + location.search;
}
function payload() {
return {
path: currentPath(),
referrer: document.referrer || null,
screen_w: window.screen ? window.screen.width : null,
screen_h: window.screen ? window.screen.height : null,
lang: navigator.language || null,
tz_offset: new Date().getTimezoneOffset(),
title: document.title || null,
ts_client: new Date().toISOString()
};
}
function notify(result) {
try {
window.dispatchEvent(new CustomEvent("zt:pageview-sent", { detail: result }));
} catch (_) {}
}
function sendPageView() {
var path = currentPath();
if (path === lastSentPath) return;
lastSentPath = path;
var body = JSON.stringify(payload());
if (debug && window.console) {
console.log("[zero-tracking] sending pageview", path, endpoint);
}
if (navigator.sendBeacon) {
var blob = new Blob([body], { type: "application/json" });
var queued = navigator.sendBeacon(endpoint, blob);
notify({ path: path, endpoint: endpoint, transport: "beacon", queued: queued });
return;
}
fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body,
keepalive: true,
credentials: "omit"
})
.then(function (response) {
notify({
path: path,
endpoint: endpoint,
transport: "fetch",
ok: response.ok,
status: response.status
});
})
.catch(function (error) {
if (debug && window.console) {
console.error("[zero-tracking] pageview failed", error);
}
notify({
path: path,
endpoint: endpoint,
transport: "fetch",
ok: false,
error: String(error)
});
});
}
function wrapHistoryMethod(methodName) {
var original = history[methodName];
if (typeof original !== "function") return;
history[methodName] = function () {
var result = original.apply(this, arguments);
setTimeout(sendPageView, 0);
return result;
};
}
wrapHistoryMethod("pushState");
wrapHistoryMethod("replaceState");
window.addEventListener("popstate", sendPageView);
if (document.readyState === "complete") {
sendPageView();
} else {
window.addEventListener("load", sendPageView, { once: true });
}
})();
Lo primero que conviene notar es que este archivo no depende de ningún framework. Todo gira alrededor de primitivas nativas del navegador: document.currentScript, location, history, sendBeacon, fetch y eventos del propio window. Esa decisión no es un detalle menor. Hace que el recolector sea más pequeño, más portable y mucho más fácil de entender.
La línea que obtiene currentScript nos permite leer atributos como data-endpoint y data-debug directamente desde la etiqueta <script>. Eso vuelve la integración bastante limpia: el mismo archivo puede reutilizarse en distintas páginas sin reescribirse, simplemente cambiando su configuración desde HTML.
Después aparece una de las piezas más importantes del archivo: lastSentPath. Esa variable cumple una función simple pero valiosa: evitar duplicados obvios. Si la ruta actual coincide con la última ruta enviada, el script no vuelve a disparar el pageview. No estamos resolviendo aquí todos los posibles casos del mundo real, pero sí evitando el tipo de ruido más inmediato en una demo y en un tutorial pedagógico.
La función payload() es el corazón semántico del tracker. Ahí se define qué significa exactamente una visita dentro de este sistema. No estamos guardando perfiles, ni identificadores persistentes, ni inventando una sesión artificial. Solo empaquetamos la ruta actual, el referrer, algunas dimensiones del dispositivo, el idioma, el desfase horario, el título de la página y un timestamp del lado del cliente. Es un conjunto mínimo, pero suficiente para producir métricas útiles.
Luego viene la decisión de transporte. Si el navegador soporta navigator.sendBeacon, usamos esa vía porque está pensada precisamente para enviar datos pequeños sin interferir demasiado con la navegación. Si no está disponible, caemos a fetch con keepalive. Esta combinación logra algo importante: priorizar un mecanismo ligero cuando existe, sin perder compatibilidad básica cuando no existe.
La función notify() añade una capa muy valiosa para depuración y enseñanza. En lugar de dejar el envío como una caja cerrada, el script emite el evento zt:pageview-sent con detalles sobre la ruta, el endpoint y el transporte utilizado. Gracias a eso, nuestras páginas /demo y /demo-spa pueden mostrar una confirmación visible de que el navegador realmente intentó mandar el evento.
Idea clave de esta implementación
tracker.js no intenta “seguir” usuarios: intenta capturar un hecho puntual del navegador —la visita a una ruta— y traducirlo en un evento pequeño, explícito y verificable.
Finalmente, la parte que adapta el sistema a navegación tipo SPA aparece en wrapHistoryMethod() y en el listener de popstate. Ahí es donde el script deja de ser un simple contador de recargas tradicionales y se vuelve un recolector capaz de convivir con interfaces modernas que cambian de URL sin recargar el documento. Esa capa es pequeña en código, pero enorme en impacto: sin ella, muchas rutas internas nunca llegarían a convertirse en métricas.
4. El backend en Rust: donde el evento deja de ser navegador y se vuelve dato
Hasta aquí ya resolvimos la mitad del problema: el navegador sabe detectar una visita y sabe enviarla. Pero una métrica todavía no existe de verdad mientras viva solo en memoria del cliente. Para que ese pageview se convierta en algo consultable, necesitamos un backend que reciba el payload, lo valide, lo normalice y lo persista.
Aquí entra Rust + Axum. Esta elección encaja muy bien con el espíritu del tutorial: queremos un sistema pequeño, explícito y con responsabilidades bien delimitadas. El backend no hace magia ni intenta resolver todos los casos del mundo. Su trabajo es mucho más concreto: exponer rutas claras, tratar el evento como datos estructurados y garantizar que solo termine en la base aquello que cumple reglas mínimas de higiene.
Esta parte del sistema también es donde se hace visible algo importante: una analítica respetuosa no significa una analítica ingenua. Aunque estemos recolectando muy poco, seguimos necesitando validaciones, sanitización y un control mínimo de abuso. Por eso el backend no solo “guarda JSON”: primero revisa que la ruta exista, que no tenga un tamaño absurdo, que el referrer sea razonable y que el flujo no esté siendo inundado de eventos anómalos.
Desde el punto de vista arquitectónico, el backend tendrá cinco responsabilidades principales. Exponer /health para comprobar que el servicio está vivo, servir /tracker.js al navegador, recibir eventos en /collect, ofrecer rutas de demostración como /demo y /demo-spa, y finalmente renderizar /dashboard con métricas agregadas. Esa combinación lo vuelve pequeño, pero completo.
Antes de abrir el archivo principal, conviene leerlo con una pregunta en mente: ¿cómo se organiza un servidor que no solo recibe eventos, sino que también sirve el tracker, las demos y el dashboard? Esa es la idea que vamos a resolver ahora.
Archivo
src/lib.rs
Este archivo concentra el corazón del backend. Aquí viven el estado compartido de la aplicación, las rutas HTTP, la inicialización de SQLite, la lógica de recolección en /collect, el dashboard y varias funciones auxiliares de sanitización y validación.
Lo que veremos a continuación no es simplemente “el servidor”. Es la pieza que convierte un evento efímero del navegador en una métrica persistida, consultable y defendible desde el punto de vista técnico.
Fíjate en la estructura mental del archivo mientras lo lees. Primero aparece el estado de la aplicación, donde compartimos la conexión a SQLite y el limitador de tasa. Luego se define el contrato de entrada con CollectPayload y las respuestas del backend. Después se construye el router con todas las rutas públicas. Más abajo se implementa la lógica real: inicialización de la base, recepción del evento, agregación para el dashboard y helpers de sanitización.
Ese orden importa porque revela una idea muy sana de diseño: primero declaramos qué piezas existen, luego cómo se conectan, y solo después ejecutamos el trabajo concreto. Es exactamente el tipo de organización que quieres en un tutorial largo: una estructura que enseñe incluso antes de entrar al detalle de cada función.
use std::{
collections::HashMap,
net::SocketAddr,
sync::Arc,
time::{Duration, Instant},
};
use axum::{
extract::{ConnectInfo, Query, State},
http::{header, HeaderMap, Method, StatusCode},
response::{Html, IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tower_http::{
cors::{Any, CorsLayer},
trace::TraceLayer,
};
#[derive(Clone)]
pub struct AppState {
pub db: Arc<Mutex<Connection>>,
pub limiter: Arc<SimpleRateLimiter>,
}
pub struct SimpleRateLimiter {
entries: Mutex<HashMap<String, Vec<Instant>>>,
max_requests: usize,
window: Duration,
}
impl SimpleRateLimiter {
pub fn new(max_requests: usize, window: Duration) -> Self {
Self {
entries: Mutex::new(HashMap::new()),
max_requests,
window,
}
}
pub async fn allow(&self, key: &str) -> bool {
let now = Instant::now();
let mut entries = self.entries.lock().await;
let slot = entries.entry(key.to_string()).or_default();
slot.retain(|t| now.duration_since(*t) <= self.window);
if slot.len() >= self.max_requests {
return false;
}
slot.push(now);
true
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct CollectPayload {
pub path: String,
pub referrer: Option<String>,
pub screen_w: Option<u32>,
pub screen_h: Option<u32>,
pub lang: Option<String>,
pub tz_offset: Option<i32>,
pub title: Option<String>,
pub ts_client: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CollectResponse {
pub ok: bool,
}
#[derive(Debug, Serialize)]
pub struct HealthResponse {
pub ok: bool,
pub now: String,
}
#[derive(Debug, Deserialize)]
pub struct DashboardParams {
pub days: Option<u32>,
}
#[derive(Debug)]
pub struct DashboardData {
pub total_events: i64,
pub top_pages: Vec<(String, i64)>,
pub top_referrers: Vec<(String, i64)>,
pub daily_views: Vec<(String, i64)>,
pub top_languages: Vec<(String, i64)>,
}
pub fn create_app(state: AppState) -> Router {
Router::new()
.route("/health", get(health))
.route("/collect", post(collect))
.route("/dashboard", get(dashboard))
.route("/tracker.js", get(tracker_js))
.route("/demo", get(demo_page))
.route("/demo-spa", get(demo_spa_page))
.layer(TraceLayer::new_for_http())
.layer(
CorsLayer::new()
.allow_methods([Method::GET, Method::POST])
.allow_headers(Any)
.allow_origin(Any),
)
.with_state(state)
}
pub async fn build_state(db_path: &str) -> Result<AppState, String> {
let conn = Connection::open(db_path).map_err(|e| format!("opening db: {e}"))?;
init_db(&conn).map_err(|e| format!("initializing db: {e}"))?;
Ok(AppState {
db: Arc::new(Mutex::new(conn)),
limiter: Arc::new(SimpleRateLimiter::new(120, Duration::from_secs(60))),
})
}
pub fn init_db(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
path TEXT NOT NULL,
referrer TEXT,
screen_w INTEGER,
screen_h INTEGER,
lang TEXT,
tz_offset INTEGER,
title TEXT,
ts_client TEXT,
received_at TEXT NOT NULL,
ip_truncated TEXT,
ua_family TEXT,
ua_raw TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_received_at ON events(received_at);
CREATE INDEX IF NOT EXISTS idx_events_path ON events(path);
CREATE INDEX IF NOT EXISTS idx_events_referrer ON events(referrer);
"#,
)
}
async fn health() -> impl IntoResponse {
Json(HealthResponse {
ok: true,
now: Utc::now().to_rfc3339(),
})
}
async fn tracker_js() -> impl IntoResponse {
let js = include_str!("static/tracker.js");
(
[(header::CONTENT_TYPE, "application/javascript; charset=utf-8")],
js,
)
}
async fn demo_page() -> impl IntoResponse {
Html(include_str!("static/demo.html"))
}
async fn demo_spa_page() -> impl IntoResponse {
Html(include_str!("static/demo_spa.html"))
}
async fn collect(
State(state): State<AppState>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(payload): Json<CollectPayload>,
) -> Response {
if payload.path.trim().is_empty() {
return error_response(StatusCode::BAD_REQUEST, "path is required");
}
if payload.path.len() > 2048 {
return error_response(StatusCode::BAD_REQUEST, "path is too long");
}
let ip_key = truncate_ip(addr.ip().to_string().as_str());
if !state.limiter.allow(&ip_key).await {
return error_response(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded");
}
let user_agent_raw = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let normalized = normalize_payload(payload);
let ua_family = classify_user_agent(&user_agent_raw);
let conn = state.db.lock().await;
let result = conn.execute(
r#"
INSERT INTO events (
event_type, path, referrer, screen_w, screen_h, lang, tz_offset,
title, ts_client, received_at, ip_truncated, ua_family, ua_raw
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
"#,
params![
"pageview",
normalized.path,
normalized.referrer,
normalized.screen_w,
normalized.screen_h,
normalized.lang,
normalized.tz_offset,
normalized.title,
normalized.ts_client,
now_sqlite(),
ip_key,
ua_family,
user_agent_raw,
],
);
match result {
Ok(_) => Json(CollectResponse { ok: true }).into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &format!("db error: {e}")),
}
}
async fn dashboard(
State(state): State<AppState>,
Query(params): Query<DashboardParams>,
) -> impl IntoResponse {
let days = params.days.unwrap_or(7).clamp(1, 90);
let conn = state.db.lock().await;
match fetch_dashboard_data(&conn, days) {
Ok(data) => Html(render_dashboard(&data, days)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("dashboard error: {e}"),
),
}
}
pub fn fetch_dashboard_data(conn: &Connection, days: u32) -> rusqlite::Result<DashboardData> {
let total_events: i64 = conn.query_row(
"SELECT COUNT(*) FROM events WHERE received_at >= datetime('now', ?1)",
[format!("-{} days", days)],
|row| row.get(0),
)?;
let top_pages = query_pairs(
conn,
"SELECT path, COUNT(*) AS total FROM events WHERE received_at >= datetime('now', ?1) GROUP BY path ORDER BY total DESC, path ASC LIMIT 10",
&format!("-{} days", days),
)?;
let top_referrers = query_pairs(
conn,
"SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS label, COUNT(*) AS total FROM events WHERE received_at >= datetime('now', ?1) GROUP BY label ORDER BY total DESC, label ASC LIMIT 10",
&format!("-{} days", days),
)?;
let daily_views = query_pairs(
conn,
"SELECT substr(received_at, 1, 10) AS day, COUNT(*) AS total FROM events WHERE received_at >= datetime('now', ?1) GROUP BY day ORDER BY day DESC LIMIT 14",
&format!("-{} days", days),
)?;
let top_languages = query_pairs(
conn,
"SELECT COALESCE(NULLIF(lang, ''), '(unknown)') AS label, COUNT(*) AS total FROM events WHERE received_at >= datetime('now', ?1) GROUP BY label ORDER BY total DESC, label ASC LIMIT 10",
&format!("-{} days", days),
)?;
Ok(DashboardData {
total_events,
top_pages,
top_referrers,
daily_views,
top_languages,
})
}
fn query_pairs(conn: &Connection, sql: &str, lookback: &str) -> rusqlite::Result<Vec<(String, i64)>> {
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([lookback], |row| Ok((row.get(0)?, row.get(1)?)))?;
rows.collect()
}
pub fn render_dashboard(data: &DashboardData, days: u32) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zero-Tracking Dashboard</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 2rem; line-height: 1.4; }}
h1, h2 {{ margin-bottom: .4rem; }}
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; }}
.card {{ border: 1px solid #ddd; border-radius: 16px; padding: 1rem; box-shadow: 0 2px 12px rgba(0,0,0,.04); }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ text-align: left; padding: .45rem 0; border-bottom: 1px solid #eee; font-size: .95rem; }}
.muted {{ color: #666; }}
code {{ background: #f6f6f6; padding: .15rem .35rem; border-radius: 8px; }}
.links a {{ margin-right: .8rem; }}
</style>
</head>
<body>
<h1>Zero-Tracking Analytics</h1>
<p class="muted">Últimos {days} días · No cookies · No IDs persistentes · Dashboard mínimo</p>
<p class="links">
<a href="/demo">Demo</a>
<a href="/demo-spa">Demo SPA</a>
<a href="/health">Health</a>
</p>
<div class="grid">
<section class="card">
<h2>Total de eventos</h2>
<p style="font-size: 2rem; margin: 0;">{total_events}</p>
</section>
<section class="card">
<h2>Integración</h2>
<p>Inserta <code><script defer src="/tracker.js" data-endpoint="/collect"></script></code> en tu HTML.</p>
</section>
</div>
<div class="grid" style="margin-top: 1rem;">
<section class="card">
<h2>Top páginas</h2>
{top_pages}
</section>
<section class="card">
<h2>Top referrers</h2>
{top_referrers}
</section>
<section class="card">
<h2>Visitas por día</h2>
{daily_views}
</section>
<section class="card">
<h2>Idiomas</h2>
{top_languages}
</section>
</div>
</body>
</html>"#,
days = days,
total_events = data.total_events,
top_pages = render_table(&data.top_pages, "Ruta", "Visitas"),
top_referrers = render_table(&data.top_referrers, "Referrer", "Visitas"),
daily_views = render_table(&data.daily_views, "Día", "Visitas"),
top_languages = render_table(&data.top_languages, "Idioma", "Visitas"),
)
}
fn render_table(rows: &[(String, i64)], col_a: &str, col_b: &str) -> String {
let body = if rows.is_empty() {
String::from("<tr><td colspan=\"2\" class=\"muted\">Sin datos todavía.</td></tr>")
} else {
rows.iter()
.map(|(a, b)| format!("<tr><td>{}</td><td>{}</td></tr>", escape_html(a), b))
.collect::<Vec<_>>()
.join("")
};
format!(
"<table><thead><tr><th>{}</th><th>{}</th></tr></thead><tbody>{}</tbody></table>",
col_a, col_b, body
)
}
fn error_response(status: StatusCode, message: &str) -> Response {
(status, Json(serde_json::json!({ "ok": false, "error": message }))).into_response()
}
pub fn normalize_payload(mut payload: CollectPayload) -> CollectPayload {
payload.path = sanitize_path(&payload.path);
payload.referrer = payload.referrer.map(|v| sanitize_referrer(&v));
payload.lang = payload.lang.map(|v| truncate_and_trim(&v, 32));
payload.title = payload.title.map(|v| truncate_and_trim(&v, 120));
payload
}
pub fn sanitize_path(input: &str) -> String {
let trimmed = truncate_and_trim(input, 2048);
if trimmed.is_empty() {
"/".to_string()
} else if trimmed.starts_with('/') {
trimmed
} else {
format!("/{}", trimmed.trim_start_matches('/'))
}
}
pub fn sanitize_referrer(input: &str) -> String {
let trimmed = truncate_and_trim(input, 512);
if trimmed.is_empty() {
return String::new();
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed
} else {
String::new()
}
}
pub fn truncate_and_trim(input: &str, max_len: usize) -> String {
input.trim().chars().take(max_len).collect()
}
pub fn truncate_ip(ip: &str) -> String {
if ip.contains('.') {
let mut parts: Vec<&str> = ip.split('.').collect();
if parts.len() == 4 {
parts[3] = "0";
return parts.join(".");
}
}
if ip.contains(':') {
let parts: Vec<&str> = ip.split(':').take(4).collect();
return format!("{}::", parts.join(":"));
}
"unknown".to_string()
}
pub fn classify_user_agent(ua: &str) -> String {
let ua = ua.to_ascii_lowercase();
if ua.contains("firefox") {
"Firefox".to_string()
} else if ua.contains("edg/") {
"Edge".to_string()
} else if ua.contains("chrome") {
"Chrome".to_string()
} else if ua.contains("safari") {
"Safari".to_string()
} else if ua.contains("curl") {
"curl".to_string()
} else {
"Other".to_string()
}
}
pub fn escape_html(input: &str) -> String {
input
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
pub async fn run_server(state: AppState, addr: SocketAddr) -> Result<(), String> {
let app = create_app(state).into_make_service_with_connect_info::<SocketAddr>();
let listener = tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| format!("bind error: {e}"))?;
axum::serve(listener, app)
.await
.map_err(|e| format!("server error: {e}"))
}
pub fn seed_demo_data(conn: &Connection) -> rusqlite::Result<()> {
let rows = vec![
("/", Some("https://google.com/"), Some("es-CO"), "Chrome"),
("/", Some("https://google.com/"), Some("es-CO"), "Chrome"),
("/guias/zero-tracking", Some("https://t.co/abc"), Some("es-CO"), "Firefox"),
("/guia-rust", None, Some("en-US"), "Firefox"),
("/guias/zero-tracking", None, Some("es-CO"), "Chrome"),
];
for (path, referrer, lang, ua_family) in rows {
conn.execute(
r#"INSERT INTO events (
event_type, path, referrer, screen_w, screen_h, lang, tz_offset,
title, ts_client, received_at, ip_truncated, ua_family, ua_raw
) VALUES (?1, ?2, ?3, 1920, 1080, ?4, -300, 'Demo', ?5, ?6, '127.0.0.0', ?7, ?8)"#,
params![
"pageview",
path,
referrer,
lang,
Utc::now().to_rfc3339(),
now_sqlite(),
ua_family,
ua_family,
],
)?;
}
Ok(())
}
pub fn now_sqlite() -> String {
Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()
}
pub fn latest_event_path(conn: &Connection) -> rusqlite::Result<Option<String>> {
conn.query_row(
"SELECT path FROM events ORDER BY id DESC LIMIT 1",
[],
|row| row.get(0),
)
.optional()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_is_normalized() {
assert_eq!(sanitize_path("about"), "/about");
assert_eq!(sanitize_path(" /pricing "), "/pricing");
assert_eq!(sanitize_path(""), "/");
}
#[test]
fn referrer_requires_http_or_https() {
assert_eq!(
sanitize_referrer("https://example.com/a"),
"https://example.com/a"
);
assert_eq!(sanitize_referrer("javascript:alert(1)"), "");
}
#[test]
fn ip_is_truncated() {
assert_eq!(truncate_ip("192.168.1.55"), "192.168.1.0");
}
#[test]
fn user_agent_classification_works() {
assert_eq!(classify_user_agent("Mozilla Firefox"), "Firefox");
assert_eq!(classify_user_agent("curl/8.0"), "curl");
}
}
La primera gran virtud de este archivo es que no mezcla todo de forma caótica. Aunque concentra buena parte del backend, la estructura interna sigue una lógica bastante clara. Primero define el estado compartido de la aplicación con AppState, donde viven la conexión a SQLite y el limitador de tasa. Después declara los tipos de entrada y salida del sistema, como CollectPayload, CollectResponse y HealthResponse. Solo después de eso aparecen las rutas, la inicialización de la base y la lógica real del servidor.
Esa organización importa porque nos dice cómo pensar el backend. No empieza por “guardar datos”, sino por responder una pregunta más básica: ¿qué piezas existen y qué responsabilidades tiene cada una? En un tutorial como este, ese orden es especialmente valioso porque evita que el lector perciba el archivo como una masa de funciones inconexas.
El router construido en create_app() deja ver, de un vistazo, el alcance real del proyecto. Aquí no solo existe /collect. También están /health, /tracker.js, /demo, /demo-spa y /dashboard. Eso significa que el backend no se limita a persistir eventos: también sirve el tracker, las demos y la visualización final. Es pequeño, sí, pero ya se comporta como una aplicación web completa.
La función build_state() marca el momento en que la aplicación deja de ser definición y empieza a conectarse con recursos reales. Ahí se abre la base SQLite, se inicializa el esquema si hace falta y se construye el limitador de tasa. En otras palabras: ahí se prepara el terreno para que el backend pueda recibir tráfico real sin asumir que todo ya existe de antemano.
Uno de los puntos más importantes del archivo vive en collect(). Esa función es el corazón operativo del sistema. Recibe el payload, comprueba que la ruta no esté vacía, impone un límite de longitud, deriva una clave basada en IP truncada para el rate limiting, lee el User-Agent desde los headers, normaliza el contenido y finalmente inserta una fila en la tabla events. Esa secuencia es la que convierte un evento del navegador en una observación persistida y consultable.
Idea clave de /collect
El endpoint no “confía” ciegamente en el navegador. Antes de guardar un pageview, lo valida, lo limpia y le aplica una capa mínima de defensa para evitar ruido o abuso trivial.
También vale la pena fijarse en el bloque de helpers. Funciones como sanitize_path(), sanitize_referrer(), truncate_ip() y classify_user_agent() pueden parecer pequeñas, pero cumplen una función crucial: vuelven explícitas las reglas del sistema. En lugar de esconder decisiones dentro de una gran función monolítica, el backend las separa en piezas con nombre y propósito. Eso mejora tanto la legibilidad como la posibilidad de probarlas.
Por último, la zona del dashboard muestra otra transición importante. Una vez que el evento ya está en SQLite, el problema deja de ser “cómo recibirlo” y pasa a ser “cómo resumirlo”. Ahí entran fetch_dashboard_data(), las consultas agregadas y render_dashboard(). Es el punto donde la telemetría cruda deja de ser solo filas y empieza a convertirse en información que un humano puede leer.
5. main.rs: cómo levantamos el sistema completo
Después de entender el corazón del backend, necesitamos mirar la pieza que lo pone en marcha. En proyectos pequeños, main.rs suele pasar desapercibido, pero aquí cumple una función importante: convierte la lógica reutilizable de la librería en una aplicación ejecutable con configuración real.
La idea es sana y muy reusable. Toda la lógica pesada vive en la crate principal, mientras que main.rs se limita a orquestar el arranque: leer variables de entorno, construir la dirección del servidor, inicializar el estado, sembrar datos demo si hace falta y finalmente llamar a run_server(). Es una separación muy útil porque evita mezclar el “qué hace el sistema” con el “cómo lo lanzo en este entorno”.
Archivo
src/main.rs
Este archivo actúa como punto de entrada ejecutable. Lee configuración desde variables de entorno, prepara el estado de la aplicación y arranca el servidor HTTP con la dirección elegida.
Fíjate además en un detalle práctico: el puerto por defecto es 3001. Eso no es casual. En entornos de desarrollo suele haber conflictos con 3000, así que esta decisión hace que la demo sea más cómoda de ejecutar sin pelearse con otros servicios locales.
La variable SEED_DEMO_DATA también merece atención. Gracias a ella, el proyecto puede arrancar con algunos eventos precargados en SQLite, lo que permite abrir el dashboard y ver datos útiles incluso antes de interactuar con las demos. Pedagógicamente, eso es muy bueno: ayuda a que el lector valide rápido que el sistema sí está vivo.
Lee este archivo como si fuera una pequeña ceremonia de arranque. Primero intenta ejecutar real_main() y, si algo falla, imprime un error claro y termina el proceso. Después toma decisiones de configuración: dónde está la base, en qué host escuchar, qué puerto usar y si se deben sembrar datos demo. Solo cuando todo eso está resuelto construye el estado compartido y levanta el servidor.
Esa secuencia es una muy buena práctica para proyectos reales: separar la configuración del comportamiento, y hacer que el punto de entrada sea simple, explícito y fácil de depurar.
use std::{env, net::SocketAddr};
use zero_tracking_analytics::{build_state, run_server, seed_demo_data};
#[tokio::main]
async fn main() {
if let Err(err) = real_main().await {
eprintln!("error: {err}");
std::process::exit(1);
}
}
async fn real_main() -> Result<(), String> {
let db_path = env::var("APP_DB_PATH").unwrap_or_else(|_| "analytics.db".to_string());
let host = env::var("APP_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = env::var("APP_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3001);
let seed_demo = env::var("SEED_DEMO_DATA")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let addr: SocketAddr = format!("{host}:{port}")
.parse()
.map_err(|e| format!("invalid APP_HOST/APP_PORT: {e}"))?;
let state = build_state(&db_path).await?;
if seed_demo {
let conn = state.db.lock().await;
seed_demo_data(&conn).map_err(|e| format!("seeding demo data: {e}"))?;
}
println!("Zero-Tracking analytics listening on http://{addr}");
println!("Health: http://{addr}/health");
println!("Dashboard: http://{addr}/dashboard");
println!("Tracker JS: http://{addr}/tracker.js");
println!("Demo page: http://{addr}/demo");
println!("SPA demo: http://{addr}/demo-spa");
run_server(state, addr)
.await
.map_err(|e| format!("server failed on {addr}: {e}"))
}
Aunque este archivo es corto, tiene varias decisiones de diseño acertadas. La primera es usar real_main() como función que devuelve Result. Eso permite centralizar errores de arranque y mostrar mensajes legibles en vez de fallos silenciosos o panics poco informativos.
La segunda es el uso de variables de entorno como APP_DB_PATH, APP_HOST, APP_PORT y SEED_DEMO_DATA. Gracias a eso, el sistema no queda amarrado a una sola configuración rígida. El mismo proyecto puede correr con otra base, otro host o un puerto distinto sin tocar el código fuente.
Finalmente, este archivo deja algo muy claro para el lector: levantar el sistema no exige magia. Basta con construir el estado, imprimir algunas URLs útiles y delegar el trabajo real a run_server(). Es una muy buena forma de cerrar la parte del arranque y pasar a una capa más visible: las demos y la validación del flujo completo.
6. Las demos: por qué /demo y /demo-spa vuelven verificable el tutorial
Una de las mejores decisiones de este proyecto es no dejar la validación en el aire. En vez de pedirle al lector que “confíe” en que el tracker funciona, el backend expone dos rutas concretas para demostrarlo. /demo sirve como prueba básica: al cargar la página, debe dispararse un pageview real. /demo-spa, en cambio, pone a prueba algo más interesante: que la navegación interna sin recarga también termine convertida en métricas.
Esta diferencia es importantísima desde el punto de vista pedagógico. La primera demo valida el caso más simple: una página normal donde el navegador carga el documento y el tracker envía el evento. La segunda valida un caso moderno: una interfaz donde la URL cambia sin recargar, y el sistema necesita observar pushState, replaceState y popstate para no quedarse ciego.
Además, ambas demos aprovechan el evento zt:pageview-sent para mostrar en pantalla una confirmación del envío. Eso es muy valioso para el lector, porque reduce la sensación de “caja negra”. No solo puede mirar luego el dashboard: también puede ver, en la propia demo, qué ruta se intentó enviar y qué transporte usó el tracker.
Qué validan estas rutas
/demo valida la captura básica de un pageview tradicional. /demo-spa valida que los cambios de ruta sin recarga también generen métricas visibles en el dashboard.
Archivos
src/static/demo.html y src/static/demo_spa.html
Estas dos páginas no son decoración. Son instrumentos de validación. La primera demuestra el envío de un pageview en una carga tradicional y la segunda comprueba que el tracker también funciona cuando la URL cambia sin recargar el documento.
Gracias a estas páginas, el tutorial no se queda en teoría ni en pruebas manuales abstractas. El lector puede abrir una URL, interactuar con ella y ver el efecto tanto en la propia interfaz como en el dashboard.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Demo Zero-Tracking</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; line-height: 1.5; }
.card { border: 1px solid #ddd; border-radius: 16px; padding: 1rem 1.2rem; max-width: 760px; }
code { background: #f6f6f6; padding: .15rem .35rem; border-radius: 8px; }
.log { margin-top: 1rem; padding: 1rem; border-radius: 12px; background: #fafafa; border: 1px solid #eee; }
a { margin-right: .75rem; }
</style>
</head>
<body>
<div class="card">
<h1>Demo Zero-Tracking</h1>
<p>Esta página sí envía un <code>pageview</code> al backend cuando la cargas.</p>
<p>
Luego puedes revisar:
<a href="/dashboard">dashboard</a>
<a href="/demo-spa">demo SPA</a>
<a href="/health">health</a>
</p>
<div class="log" id="status">Esperando señal del tracker…</div>
</div>
<script>
window.addEventListener("zt:pageview-sent", function (event) {
var detail = event.detail || {};
document.getElementById("status").textContent =
"Evento enviado. Ruta=" + (detail.path || "?") +
" · transporte=" + (detail.transport || "?") +
(detail.status ? " · status=" + detail.status : "");
});
</script>
<script defer src="/tracker.js" data-endpoint="/collect" data-debug="true"></script>
</body>
</html>
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SPA Demo</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; line-height: 1.5; }
.card { border: 1px solid #ddd; border-radius: 16px; padding: 1rem 1.2rem; max-width: 760px; }
button { margin-right: .75rem; margin-top: .5rem; padding: .65rem .9rem; }
.log { margin-top: 1rem; padding: 1rem; border-radius: 12px; background: #fafafa; border: 1px solid #eee; }
a { margin-right: .75rem; }
</style>
</head>
<body>
<div class="card">
<h1>SPA Demo</h1>
<p>Pulsa los botones para cambiar la ruta sin recargar. Cada cambio debe crear un nuevo pageview.</p>
<p>
<a href="/dashboard">dashboard</a>
<a href="/demo">demo simple</a>
</p>
<button id="home">Home</button>
<button id="pricing">Pricing</button>
<button id="post">Post</button>
<div class="log" id="status">Ruta actual: <span id="route"></span></div>
</div>
<script>
function showRoute() {
document.getElementById("route").textContent = location.pathname + location.search;
}
document.getElementById("home").onclick = function () {
history.pushState({}, "", "/demo-spa");
showRoute();
};
document.getElementById("pricing").onclick = function () {
history.pushState({}, "", "/pricing?from=spa");
showRoute();
};
document.getElementById("post").onclick = function () {
history.pushState({}, "", "/blog/zero-tracking?step=2");
showRoute();
};
window.addEventListener("zt:pageview-sent", function (event) {
var detail = event.detail || {};
document.getElementById("status").textContent =
"Ruta actual: " + (location.pathname + location.search) +
" · último envío=" + (detail.path || "?") +
" · transporte=" + (detail.transport || "?");
});
showRoute();
</script>
<script defer src="/tracker.js" data-endpoint="/collect" data-debug="true"></script>
</body>
</html>
Comentarios y valoraciones
No hay comentarios aún. ¡Sé el primero en opinar!