Un script tuyo mide qué sección leen y dónde se van
Web · Intermedio
Analytics propio en tu landingsaber qué sección leen y dónde se van, sin Google Analytics
Instalé Google Analytics en las primeras landings que hice para clientes y nunca me contestó la pregunta que importaba: ¿en qué parte de la página se va la gente? Tag Manager, eventos de scroll, un plugin, y aun así el dato llegaba tarde, muestreado y mezclado con tráfico que no era humano. Al final escribí un tracker propio y lo fui puliendo en landings de eventos con decenas de miles de visitas por campaña. Esta guía es ese tracker, la ruta que lo recibe y el esquema que lo guarda, con las decisiones que tomé al romperme la cara: por qué el tiempo se mide con heartbeats y no con timestamps, por qué la conversión se divide entre dos denominadores, y por qué el dashboard jamás debe sumar tablas grandes al momento de abrirlo.
De un vistazo
Los eventos viajan en lotes a tu propia tabla
Cuatro métricas que cambian decisiones, no cincuenta
01 el punto de partida
Qué te da un tracker propio que GA no
No es una cruzada contra Google Analytics. Es que GA está diseñado para sitios con muchas páginas y tu landing es una sola página larga. Lo que necesitas saber es qué pasa dentro de esa página, y ahí GA te obliga a configurar eventos a mano, te muestrea cuando hay volumen y te esconde los datos crudos. Con tu propia tabla en Postgres haces la pregunta que quieras con SQL.
Cuántos segundos reales estuvo cada visitante con cada sección en pantalla. Es el mapa de calor que de verdad cambia el copy: si la sección de precios tiene 40 segundos de promedio y la de testimonios 2, ya sabes dónde está la duda.
Hasta dónde llegó cada sesión. Cruzado con la fuente te dice, por ejemplo, que el tráfico de un anuncio nunca pasa del 50% mientras el orgánico llega al final.
No solo cuántos convirtieron: desde qué sección hicieron clic en el CTA, cuánto llevaban en la página, en qué dispositivo. Y con un denominador que tú controlas.
Sin muestreo, sin retención de 14 meses, sin consentimiento de terceros. Exportas a CSV, unes con tu CRM por sesión y lo que quieras.
El costo real es el que no se ve: mantener tres tablas y una ruta. En mi experiencia son dos horas la primera vez y cero después, porque el tracker es el mismo en todas las landings. Lo que cambia por proyecto es el slug del embudo.
02 las tres piezas
Cómo fluye un evento de la pantalla a la tabla
- El tracker arranca en el cliente, arma el contexto de la sesión (ids, dispositivo, UTMs, referrer) y manda un session_init.
- Cada interacción genera un evento en una cola en memoria: section_view, section_leave con dwell_ms, scroll en marcas de 25/50/75/90/100, click, form_focus, conversion.
- Un heartbeat cada 10 segundos acumula tiempo visible. Si la pestaña se oculta, el heartbeat se pausa; si vuelve, se reanuda.
- La cola se vacía cada 3 segundos o al juntar 10 eventos, con fetch y keepalive. Si falla, los eventos regresan al frente de la cola.
- Cuando la pestaña se oculta (visibilitychange a hidden, o pagehide como respaldo) el último lote sale con navigator.sendBeacon, que el navegador entrega aunque la página ya no exista.
- La API route valida, marca bots por user-agent, sanea UTMs, hace upsert de la sesión, inserta los eventos y actualiza los agregados de esa sesión (tiempo total, scroll máximo, clics, conversión).
- Un cron refresca el rollup diario. El dashboard solo lee ese rollup, nunca suma la tabla de eventos.
| Evento | Cuándo se dispara | Campo clave |
|---|---|---|
| session_init | Al cargar, una vez por sesión | meta.ctx con todo el contexto |
| section_view | Una sección [data-section] cubre 50% del viewport | section_id |
| section_leave | Esa sección deja de cubrir el 50% | section_id, dwell_ms |
| scroll | Al cruzar 25, 50, 75, 90 o 100% | scroll_pct |
| heartbeat | Cada 10 s con la pestaña visible | dwell_ms del intervalo |
| click | Clic en a, button o [data-track] | target, value (href), section_id |
| form_focus | Primer foco en un input del formulario | target (name del campo) |
| conversion | Lo llamas tú tras el submit exitoso | meta libre |
| exit | Pestaña oculta o cerrada | scroll_pct máximo, dwell_ms total |
03 pieza 1: cliente
El tracker completo
Copia este archivo tal cual en lib/tracker.ts. No importa nada, no usa cookies, y lo único que persiste es un id de visitante en localStorage y un id de sesión en sessionStorage. Las secciones se marcan en el HTML con data-section="precios"; los botones que quieras nombrar, con data-track="cta-hero".
git clone https://github.com/davidiriza-lab/analytics-propio-landing.git
export type TrackerEventType =
| "session_init" | "heartbeat" | "section_view" | "section_leave"
| "click" | "scroll" | "form_focus" | "conversion" | "exit";
export interface TrackerEvent {
type: TrackerEventType;
target?: string;
value?: string;
section_id?: string;
scroll_pct?: number;
dwell_ms?: number;
meta?: Record<string, unknown>;
t?: number;
}
export interface SessionContext {
session_id: string;
visitor_id: string;
device_type: string;
viewport_w: number;
viewport_h: number;
language: string;
timezone: string;
referrer_host: string;
landing_path: string;
utm_source: string;
utm_medium: string;
utm_campaign: string;
utm_content: string;
utm_term: string;
fbclid: string;
gclid: string;
}
const ENDPOINT = "/api/track";
const FLUSH_MS = 3_000;
const FLUSH_SIZE = 10;
const HEARTBEAT_MS = 10_000;
const SCROLL_MARKS = [25, 50, 75, 90, 100];
function uuid(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
});
}
function storedId(storage: Storage, key: string): string {
try {
const existing = storage.getItem(key);
if (existing) return existing;
const fresh = uuid();
storage.setItem(key, fresh);
return fresh;
} catch {
return uuid();
}
}
function deviceType(): string {
const ua = navigator.userAgent;
const tablet = /iPad|Tablet|Silk/i.test(ua) || (/Android/i.test(ua) && !/Mobile/i.test(ua));
if (tablet) return "tablet";
return /Mobile|Android|iPhone|iPod/i.test(ua) ? "mobile" : "desktop";
}
function buildContext(): SessionContext {
const q = new URLSearchParams(window.location.search);
const get = (k: string): string => q.get(k) ?? "";
let referrer_host = "";
try { referrer_host = document.referrer ? new URL(document.referrer).host : ""; } catch { /* vacío */ }
return {
session_id: storedId(sessionStorage, "_sid"),
visitor_id: storedId(localStorage, "_vid"),
device_type: deviceType(),
viewport_w: window.innerWidth,
viewport_h: window.innerHeight,
language: navigator.language ?? "",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? "",
referrer_host,
landing_path: window.location.pathname + window.location.search,
utm_source: get("utm_source"),
utm_medium: get("utm_medium"),
utm_campaign: get("utm_campaign"),
utm_content: get("utm_content"),
utm_term: get("utm_term"),
fbclid: get("fbclid"),
gclid: get("gclid"),
};
}
class Tracker {
private ctx: SessionContext;
private queue: TrackerEvent[] = [];
private flushTimer: number | null = null;
private sectionEnter: Record<string, number> = {};
private maxScroll = 0;
private lastTick = Date.now();
private totalMs = 0;
private visible = true;
private started = false;
private exited = false;
constructor() { this.ctx = buildContext(); }
start(): void {
if (this.started) return;
this.started = true;
this.track({ type: "session_init", meta: { ctx: this.ctx } });
window.setInterval(() => this.heartbeat(), HEARTBEAT_MS);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
this.heartbeat();
this.visible = false;
this.flushBeacon();
} else {
this.visible = true;
this.exited = false;
this.lastTick = Date.now();
}
});
window.addEventListener("pagehide", () => this.flushBeacon());
let ticking = false;
window.addEventListener("scroll", () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const pct = this.scrollPct();
if (pct > this.maxScroll) {
const crossed = SCROLL_MARKS.filter((m) => m > this.maxScroll && m <= pct);
this.maxScroll = pct;
for (const m of crossed) this.track({ type: "scroll", scroll_pct: m });
}
ticking = false;
});
}, { passive: true });
this.observeSections();
document.addEventListener("click", (ev) => {
const el = (ev.target as HTMLElement | null)?.closest<HTMLElement>("[data-track], a, button");
if (!el) return;
this.track({
type: "click",
target: el.getAttribute("data-track") ?? this.labelOf(el),
value: el instanceof HTMLAnchorElement ? el.href : "",
section_id: this.sectionOf(el),
});
}, { capture: true });
let formFocused = false;
document.addEventListener("focusin", (ev) => {
if (formFocused) return;
const el = ev.target as HTMLElement;
if (!el.matches("input, textarea, select")) return;
formFocused = true;
this.track({
type: "form_focus",
target: el.getAttribute("name") ?? el.id ?? "campo",
section_id: this.sectionOf(el),
});
});
}
conversion(meta: Record<string, unknown> = {}): void {
this.track({ type: "conversion", meta });
this.flush();
}
track(ev: TrackerEvent): void {
this.queue.push({ ...ev, t: Date.now() });
if (this.queue.length >= FLUSH_SIZE) this.flush();
else this.scheduleFlush();
}
private heartbeat(): void {
if (!this.visible) return;
const now = Date.now();
const delta = now - this.lastTick;
this.lastTick = now;
this.totalMs += delta;
this.track({ type: "heartbeat", dwell_ms: delta, scroll_pct: this.maxScroll, meta: { total_ms: this.totalMs } });
}
private scrollPct(): number {
const h = document.documentElement;
const total = (h.scrollHeight - h.clientHeight) || 1;
return Math.min(100, Math.max(0, Math.round((h.scrollTop / total) * 100)));
}
private observeSections(): void {
const sections = document.querySelectorAll<HTMLElement>("[data-section]");
if (!sections.length) return;
const obs = new IntersectionObserver((entries) => {
for (const e of entries) {
const id = (e.target as HTMLElement).dataset.section ?? "";
if (!id) continue;
if (e.isIntersecting) {
this.sectionEnter[id] = Date.now();
this.track({ type: "section_view", section_id: id });
} else if (this.sectionEnter[id]) {
const dwell = Date.now() - this.sectionEnter[id];
delete this.sectionEnter[id];
this.track({ type: "section_leave", section_id: id, dwell_ms: dwell });
}
}
}, { threshold: 0.5 });
sections.forEach((s) => obs.observe(s));
}
private closeOpenSections(): void {
const now = Date.now();
for (const [id, since] of Object.entries(this.sectionEnter)) {
this.queue.push({ type: "section_leave", section_id: id, dwell_ms: now - since, t: now });
this.sectionEnter[id] = now;
}
}
private sectionOf(el: HTMLElement): string {
return el.closest<HTMLElement>("[data-section]")?.dataset.section ?? "";
}
private labelOf(el: HTMLElement): string {
const text = (el.textContent ?? "").trim().slice(0, 60);
return el.getAttribute("aria-label") ?? (el.id || text || el.tagName.toLowerCase());
}
private scheduleFlush(): void {
if (this.flushTimer != null) return;
this.flushTimer = window.setTimeout(() => this.flush(), FLUSH_MS);
}
private flush(): void {
if (this.flushTimer != null) { window.clearTimeout(this.flushTimer); this.flushTimer = null; }
if (this.queue.length === 0) return;
const batch = this.queue.splice(0, this.queue.length);
const body = JSON.stringify({ ctx: this.ctx, events: batch });
fetch(ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true })
.catch(() => { this.queue.unshift(...batch); });
}
private flushBeacon(): void {
if (this.exited) return;
this.exited = true;
this.closeOpenSections();
this.queue.push({ type: "exit", scroll_pct: this.maxScroll, dwell_ms: this.totalMs, t: Date.now() });
const body = JSON.stringify({ ctx: this.ctx, events: this.queue });
this.queue = [];
if ("sendBeacon" in navigator) {
navigator.sendBeacon(ENDPOINT, new Blob([body], { type: "application/json" }));
} else {
fetch(ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true }).catch(() => {});
}
}
}
let singleton: Tracker | null = null;
export function getTracker(): Tracker {
if (!singleton) singleton = new Tracker();
return singleton;
}
export function initTracker(): void {
if (typeof window === "undefined") return;
getTracker().start();
}Cuatro decisiones que no son obvias leyendo el código:
- threshold: 0.5 en el IntersectionObserver. Con 0 (el default) una sección 'se ve' cuando asoma un pixel; con 0.5 cuenta cuando la mitad está en pantalla. En móvil, donde el viewport es corto, 0.5 sigue funcionando porque las secciones de una landing suelen ser más altas que la pantalla, así que tienes que ajustar el umbral si tus secciones miden menos de la mitad del viewport.
- El tiempo se mide con heartbeats, no restando timestamps de entrada y salida. Si alguien deja la pestaña abierta en segundo plano tres horas, el heartbeat se pausa con visibilitychange y esas tres horas no cuentan. Con timestamps contarían.
- La salida se manda en visibilitychange a hidden, y pagehide es solo respaldo. MDN lo dice explícito: unload y beforeunload no son confiables en móvil, no se disparan al cambiar de app y sacan la página del bfcache. Antes usaba beforeunload y perdía la mayoría de las salidas de iPhone.
- sendBeacon recibe un Blob con type application/json. Si mandas un string plano, el navegador lo envía como text/plain y tu ruta tiene que adivinar. El límite del beacon es de unos 64 KiB; un lote de 10 eventos pesa menos de 3 KB.
En Next.js App Router, el tracker se monta desde un client component vacío que vive en el layout de la landing:
"use client";
import { useEffect } from "react";
import { initTracker } from "@/lib/tracker";
export function TrackerMount(): null {
useEffect(() => { initTracker(); }, []);
return null;
}
// En app/layout.tsx (o el layout de la landing):
// <body>{children}<TrackerMount /></body>
//
// En el formulario, tras el submit exitoso:
// import { getTracker } from "@/lib/tracker";
// getTracker().conversion({ form: "registro" });04 pieza 2: servidor
La ruta que recibe los lotes
Una sola ruta POST. Recibe el contexto y el lote de eventos, y hace cuatro cosas antes de tocar la base: valida que haya sesión y eventos, marca bots por user-agent, sanea los UTMs y trunca todo a un largo máximo. Lo que llega de un navegador es hostil por definición; truncar es la vacuna más barata contra alguien que te mande un utm_campaign de 2 MB. Escribe con la service role key desde el servidor; el cliente nunca ve una credencial.
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
const FUNNEL = process.env.FUNNEL_SLUG ?? "landing";
const db = createClient(
process.env.SUPABASE_URL as string,
process.env.SUPABASE_SERVICE_ROLE_KEY as string,
{ auth: { persistSession: false } },
);
interface EventIn {
type: string;
target?: string;
value?: string;
section_id?: string;
scroll_pct?: number;
dwell_ms?: number;
meta?: Record<string, unknown>;
t?: number;
}
interface ContextIn {
session_id: string;
visitor_id?: string;
device_type?: string;
viewport_w?: number;
viewport_h?: number;
language?: string;
timezone?: string;
referrer_host?: string;
landing_path?: string;
utm_source?: string;
utm_medium?: string;
utm_campaign?: string;
utm_content?: string;
utm_term?: string;
fbclid?: string;
gclid?: string;
}
interface PayloadIn { ctx: ContextIn; events: EventIn[] }
interface SessionSnapshot {
total_time_ms: number;
max_scroll_pct: number;
click_count: number;
event_count: number;
converted: boolean;
converted_at: string | null;
}
const BOT_UA = /bot|crawler|spider|headless|lighthouse|pagespeed|preview|pingdom|uptimerobot|wget|curl/i;
function cut(s: string | undefined, max: number): string {
return (s ?? "").toString().slice(0, max);
}
function utm(value: string | undefined): string {
let s = (value ?? "").trim();
try { s = decodeURIComponent(s.replace(/\+/g, " ")); } catch { /* se queda como llegó */ }
s = s.trim();
return /^\{\{.+\}\}$/.test(s) ? "" : s;
}
function geo(req: NextRequest, header: string): string {
try { return decodeURIComponent(req.headers.get(header) ?? ""); } catch { return ""; }
}
export async function POST(req: NextRequest): Promise<NextResponse> {
let payload: PayloadIn;
try {
payload = (await req.json()) as PayloadIn;
} catch {
return NextResponse.json({ ok: false, error: "invalid_json" }, { status: 400 });
}
const { ctx, events } = payload;
if (!ctx?.session_id || !Array.isArray(events) || events.length === 0 || events.length > 100) {
return NextResponse.json({ ok: false, error: "bad_payload" }, { status: 400 });
}
const userAgent = req.headers.get("user-agent") ?? "";
const ip = (req.headers.get("x-forwarded-for") ?? "").split(",")[0].trim();
const sessionRow = {
session_id: cut(ctx.session_id, 64),
funnel_slug: FUNNEL,
visitor_id: cut(ctx.visitor_id, 64),
ip: cut(ip, 64),
ip_country: geo(req, "x-vercel-ip-country"),
ip_region: geo(req, "x-vercel-ip-country-region"),
ip_city: geo(req, "x-vercel-ip-city"),
user_agent: cut(userAgent, 512),
device_type: cut(ctx.device_type, 16),
viewport_w: ctx.viewport_w ?? 0,
viewport_h: ctx.viewport_h ?? 0,
language: cut(ctx.language, 16),
timezone: cut(ctx.timezone, 64),
referrer_host: cut(ctx.referrer_host, 256),
landing_path: cut(ctx.landing_path, 512),
utm_source: cut(utm(ctx.utm_source), 128),
utm_medium: cut(utm(ctx.utm_medium), 128),
utm_campaign: cut(utm(ctx.utm_campaign), 256),
utm_content: cut(utm(ctx.utm_content), 256),
utm_term: cut(utm(ctx.utm_term), 256),
fbclid: cut(ctx.fbclid, 256),
gclid: cut(ctx.gclid, 256),
is_bot: BOT_UA.test(userAgent),
};
const { error: sessErr } = await db
.from("sessions")
.upsert(sessionRow, { onConflict: "session_id", ignoreDuplicates: true });
if (sessErr) {
return NextResponse.json({ ok: false, error: sessErr.message }, { status: 500 });
}
let dwell = 0;
let maxScroll = 0;
let clicks = 0;
let converted = false;
const dwellBySection: Record<string, number> = {};
const rows = events.map((ev) => {
if (ev.type === "heartbeat" && typeof ev.dwell_ms === "number") dwell += ev.dwell_ms;
if (typeof ev.scroll_pct === "number" && ev.scroll_pct > maxScroll) maxScroll = ev.scroll_pct;
if (ev.type === "click") clicks += 1;
if (ev.type === "conversion") converted = true;
if (ev.type === "section_leave" && ev.section_id && typeof ev.dwell_ms === "number") {
dwellBySection[ev.section_id] = (dwellBySection[ev.section_id] ?? 0) + ev.dwell_ms;
}
return {
session_id: sessionRow.session_id,
funnel_slug: FUNNEL,
event_type: cut(ev.type, 32),
event_target: cut(ev.target, 256),
event_value: cut(ev.value, 512),
section_id: cut(ev.section_id, 64),
scroll_pct: ev.scroll_pct ?? null,
dwell_ms: ev.dwell_ms ?? null,
meta: ev.meta ?? {},
created_at: ev.t ? new Date(ev.t).toISOString() : new Date().toISOString(),
};
});
const { error: evErr } = await db.from("track_events").insert(rows);
if (evErr) {
return NextResponse.json({ ok: false, error: evErr.message }, { status: 500 });
}
const { data: curr } = await db
.from("sessions")
.select("total_time_ms, max_scroll_pct, click_count, event_count, converted, converted_at")
.eq("session_id", sessionRow.session_id)
.maybeSingle<SessionSnapshot>();
const c: SessionSnapshot = curr ?? {
total_time_ms: 0, max_scroll_pct: 0, click_count: 0, event_count: 0, converted: false, converted_at: null,
};
await db.from("sessions").update({
total_time_ms: c.total_time_ms + dwell,
max_scroll_pct: Math.max(c.max_scroll_pct, maxScroll),
click_count: c.click_count + clicks,
event_count: c.event_count + rows.length,
last_seen_at: new Date().toISOString(),
converted: c.converted || converted,
converted_at: c.converted_at ?? (converted ? new Date().toISOString() : null),
}).eq("session_id", sessionRow.session_id);
for (const [sectionId, ms] of Object.entries(dwellBySection)) {
await db.rpc("upsert_section_dwell", {
p_session_id: sessionRow.session_id,
p_section_id: sectionId,
p_ms: ms,
});
}
return NextResponse.json({ ok: true, count: rows.length });
}is_bot es una columna, y todas las consultas filtran WHERE NOT is_bot. Así conservas la evidencia. En una landing con pauta, entre el 15 y el 30% de las sesiones son crawlers de las propias plataformas de anuncios y previsualizadores de mensajería.
Las plataformas de anuncios mandan el nombre de campaña codificado (Bootcamp%20Verano) y, cuando el anuncio está mal configurado, mandan la plantilla sin rellenar: {{campaign.name}}. Sin este saneado terminas con veinte 'fuentes' que son la misma.
Vercel inyecta x-vercel-ip-country, x-vercel-ip-country-region y x-vercel-ip-city en cada request. La ciudad llega codificada según RFC 3986 (Guadalajara sin problema, pero Cancún llega como Canc%C3%BAn), por eso el decodeURIComponent.
La primera vez crea la sesión con su contexto; los lotes siguientes no la pisan. Si no lo pones, un lote tardío con contexto vacío te borra los UTMs de la sesión.
05 pieza 3: base de datos
El esquema SQL mínimo
Tres tablas de escritura, una función atómica y una tabla de rollup. Es idempotente: la corres en el editor SQL de tu proyecto tantas veces como quieras. El funnel_slug en todas las tablas es lo que te deja usar la misma base para varias landings.
-- 1. Una fila por sesión, con el contexto y los agregados de esa sesión
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
funnel_slug TEXT NOT NULL DEFAULT 'landing',
visitor_id TEXT NOT NULL DEFAULT '',
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
total_time_ms BIGINT NOT NULL DEFAULT 0,
max_scroll_pct INTEGER NOT NULL DEFAULT 0,
event_count INTEGER NOT NULL DEFAULT 0,
click_count INTEGER NOT NULL DEFAULT 0,
converted BOOLEAN NOT NULL DEFAULT false,
converted_at TIMESTAMPTZ,
ip TEXT NOT NULL DEFAULT '',
ip_country TEXT NOT NULL DEFAULT '',
ip_region TEXT NOT NULL DEFAULT '',
ip_city TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
device_type TEXT NOT NULL DEFAULT '',
viewport_w INTEGER NOT NULL DEFAULT 0,
viewport_h INTEGER NOT NULL DEFAULT 0,
language TEXT NOT NULL DEFAULT '',
timezone TEXT NOT NULL DEFAULT '',
referrer_host TEXT NOT NULL DEFAULT '',
landing_path TEXT NOT NULL DEFAULT '/',
utm_source TEXT NOT NULL DEFAULT '',
utm_medium TEXT NOT NULL DEFAULT '',
utm_campaign TEXT NOT NULL DEFAULT '',
utm_content TEXT NOT NULL DEFAULT '',
utm_term TEXT NOT NULL DEFAULT '',
fbclid TEXT NOT NULL DEFAULT '',
gclid TEXT NOT NULL DEFAULT '',
is_bot BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX IF NOT EXISTS idx_sessions_first_seen ON sessions (funnel_slug, first_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions (funnel_slug, utm_source);
-- 2. Eventos crudos. Es la tabla que crece; nunca se consulta entera desde el dashboard
CREATE TABLE IF NOT EXISTS track_events (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL,
funnel_slug TEXT NOT NULL DEFAULT 'landing',
event_type TEXT NOT NULL,
event_target TEXT NOT NULL DEFAULT '',
event_value TEXT NOT NULL DEFAULT '',
section_id TEXT NOT NULL DEFAULT '',
scroll_pct INTEGER,
dwell_ms BIGINT,
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_events_session ON track_events (session_id);
CREATE INDEX IF NOT EXISTS idx_events_type ON track_events (funnel_slug, event_type, created_at DESC);
-- 3. Dwell acumulado por sesión y sección (lo que alimenta el mapa de calor)
CREATE TABLE IF NOT EXISTS section_dwell (
session_id TEXT NOT NULL,
section_id TEXT NOT NULL,
total_ms BIGINT NOT NULL DEFAULT 0,
view_count INTEGER NOT NULL DEFAULT 0,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (session_id, section_id)
);
CREATE INDEX IF NOT EXISTS idx_section_dwell_section ON section_dwell (section_id);
-- 4. Upsert atómico: dos lotes de la misma sesión no se pisan
CREATE OR REPLACE FUNCTION upsert_section_dwell(p_session_id TEXT, p_section_id TEXT, p_ms BIGINT)
RETURNS VOID AS $$
BEGIN
INSERT INTO section_dwell (session_id, section_id, total_ms, view_count, last_seen_at)
VALUES (p_session_id, p_section_id, p_ms, 1, now())
ON CONFLICT (session_id, section_id) DO UPDATE
SET total_ms = section_dwell.total_ms + EXCLUDED.total_ms,
view_count = section_dwell.view_count + 1,
last_seen_at = now();
END;
$$ LANGUAGE plpgsql;
-- 5. Rollup diario: una fila por embudo y día (en tu zona horaria). El dashboard lee de aquí
CREATE TABLE IF NOT EXISTS stats_daily (
funnel_slug TEXT NOT NULL,
day DATE NOT NULL,
sessions INTEGER NOT NULL DEFAULT 0,
qualified_sessions INTEGER NOT NULL DEFAULT 0, -- >= 3 s visibles
conversions INTEGER NOT NULL DEFAULT 0,
avg_time_ms BIGINT NOT NULL DEFAULT 0,
avg_scroll_pct NUMERIC NOT NULL DEFAULT 0,
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (funnel_slug, day)
);
-- 6. Refresca los últimos N días (los lotes tardíos siguen llegando después de medianoche)
CREATE OR REPLACE FUNCTION refresh_stats_daily(p_days INTEGER DEFAULT 4, p_tz TEXT DEFAULT 'America/Mexico_City')
RETURNS VOID AS $$
BEGIN
INSERT INTO stats_daily (funnel_slug, day, sessions, qualified_sessions, conversions, avg_time_ms, avg_scroll_pct, refreshed_at)
SELECT
funnel_slug,
(first_seen_at AT TIME ZONE p_tz)::date AS day,
COUNT(*) AS sessions,
COUNT(*) FILTER (WHERE total_time_ms >= 3000) AS qualified_sessions,
COUNT(*) FILTER (WHERE converted) AS conversions,
COALESCE(AVG(total_time_ms), 0)::bigint AS avg_time_ms,
ROUND(COALESCE(AVG(max_scroll_pct), 0), 1) AS avg_scroll_pct,
now()
FROM sessions
WHERE NOT is_bot
AND first_seen_at >= ((now() AT TIME ZONE p_tz)::date - p_days) AT TIME ZONE p_tz
GROUP BY funnel_slug, (first_seen_at AT TIME ZONE p_tz)::date
ON CONFLICT (funnel_slug, day) DO UPDATE
SET sessions = EXCLUDED.sessions,
qualified_sessions = EXCLUDED.qualified_sessions,
conversions = EXCLUDED.conversions,
avg_time_ms = EXCLUDED.avg_time_ms,
avg_scroll_pct = EXCLUDED.avg_scroll_pct,
refreshed_at = now();
END;
$$ LANGUAGE plpgsql;Si expones estas tablas en Supabase, revoca EXECUTE de las funciones para el rol anon y deja RLS activado sin políticas de lectura pública. La única puerta de entrada es tu API route con la service role key; el dashboard también lee por el servidor.
06 lo que sí importa
Las cuatro métricas que cambian decisiones
Puedes sacar cincuenta gráficas de estas tablas. Estas cuatro son las que en la práctica movieron copy, orden de secciones o presupuesto de pauta. Todas filtran bots y todas se pueden correr tal cual.
1. Dwell por sección
SELECT
d.section_id,
COUNT(DISTINCT d.session_id) AS sesiones_que_la_vieron,
ROUND(AVG(d.total_ms) / 1000.0, 1) AS segundos_promedio,
ROUND(100.0 * COUNT(DISTINCT d.session_id)
/ NULLIF((SELECT COUNT(*) FROM sessions s2
WHERE s2.funnel_slug = s.funnel_slug AND NOT s2.is_bot
AND s2.total_time_ms >= 3000), 0), 1) AS alcance_pct
FROM section_dwell d
JOIN sessions s ON s.session_id = d.session_id
WHERE s.funnel_slug = 'landing' AND NOT s.is_bot
GROUP BY d.section_id, s.funnel_slug
ORDER BY alcance_pct DESC;Léelo como una cascada: el alcance baja sección por sección, y el salto más grande entre dos filas consecutivas es la sección que espanta. Los segundos promedio te dicen si la leen o la saltan. Una sección con alcance alto y 1.5 segundos de promedio no la están leyendo; una con 45 segundos es donde se detienen a pensar (o donde no entienden).
2. Scroll máximo por fuente
SELECT COALESCE(NULLIF(utm_source, ''), NULLIF(referrer_host, ''), 'directo') AS fuente, COUNT(*) AS sesiones, ROUND(AVG(max_scroll_pct), 0) AS scroll_promedio, ROUND(100.0 * COUNT(*) FILTER (WHERE max_scroll_pct >= 75) / COUNT(*), 1) AS llegan_al_75_pct FROM sessions WHERE funnel_slug = 'landing' AND NOT is_bot AND total_time_ms >= 3000 GROUP BY 1 HAVING COUNT(*) >= 30 ORDER BY sesiones DESC;
3. Conversión con dos denominadores
Aquí me equivoqué y vale la pena contarlo. Definí una 'sesión calificada' como la que estuvo al menos 3 segundos visible, y usé eso como denominador de la conversión. Tiene sentido: descarta rebotes instantáneos y pestañas que se abrieron por error. El problema fue comparar ese número con la plataforma de anuncios. Nuestro dashboard decía 38% (139 conversiones entre 365 calificadas); la plataforma decía 10% (las mismas 139 entre unas 1,390 vistas de página). Ninguno mentía: la plataforma cuenta cada disparo del pixel, sin filtro de tiempo.
SELECT day, sessions, qualified_sessions, conversions, ROUND(100.0 * conversions / NULLIF(qualified_sessions, 0), 1) AS cvr_calificada_pct, -- para optimizar la página ROUND(100.0 * conversions / NULLIF(sessions, 0), 1) AS cvr_total_pct -- para comparar con la pauta FROM stats_daily WHERE funnel_slug = 'landing' ORDER BY day DESC LIMIT 30;
Muestra las dos. La calificada (≥3 s) es la que usas para mejorar la página: mide cuánto convence el copy a quien de verdad lo vio. La total es la que usas para hablar con el media buyer, porque es la que se parece a su reporte. Si solo muestras una, alguien va a decir que tus números están inflados o que la pauta está peor de lo que está.
4. Fuente por UTM, con el ad concreto
SELECT
utm_source, utm_campaign, utm_content,
COUNT(*) FILTER (WHERE total_time_ms >= 3000) AS calificadas,
COUNT(*) FILTER (WHERE converted) AS conversiones,
ROUND(AVG(total_time_ms) / 1000.0, 0) AS seg_promedio,
ROUND(100.0 * COUNT(*) FILTER (WHERE converted)
/ NULLIF(COUNT(*) FILTER (WHERE total_time_ms >= 3000), 0), 1) AS cvr_pct
FROM sessions
WHERE funnel_slug = 'landing' AND NOT is_bot AND utm_source <> ''
GROUP BY 1, 2, 3
HAVING COUNT(*) >= 50
ORDER BY conversiones DESC;Pon utm_content = nombre del anuncio en tus plantillas de URL. Es el nivel que la plataforma de pauta no te cruza con comportamiento en página, y es donde descubres que el anuncio con más clics trae gente que se va a los 4 segundos.
07 la regla de oro
Por qué el request nunca computa
El dashboard de un proyecto con 800 mil contactos y millones de eventos tardaba 18 segundos en abrir la vista de 'Todo'. La causa era obvia en retrospectiva: cada carga hacía COUNT y AVG sobre las tablas completas. Lo reescribí con el patrón que usan los sistemas grandes, un read-model, y la misma vista bajó a 78 milisegundos.
stats_daily tiene una fila por embudo y día. Cualquier rango de fechas es una SUMA de a lo mucho unos cientos de filas chicas. Un año entero se lee en un solo índice.
El día en curso se calcula al momento desde sessions, que está indexada por fecha y tiene pocas filas de hoy. Es lo único que vale la pena computar en el request.
refresh_stats_daily(4) corre cada 15 minutos y recalcula los últimos cuatro días, no solo hoy. Los lotes con sendBeacon llegan después de medianoche y las conversiones se confirman tarde; sin esa ventana el día de ayer queda incompleto para siempre.
La primera semana, corre en paralelo el rollup y el conteo directo para el mismo rango. Deben dar exactamente igual. En mi caso encontré así un bug: el rango 'Todo' pasaba la fecha actual como límite exclusivo y descartaba el día de hoy.
SELECT cron.schedule('refresh-stats-daily', '*/15 * * * *', $$SELECT refresh_stats_daily(4, 'America/Mexico_City')$$);Dos trampas más que aprendí a la mala. Una: bucketea los días con AT TIME ZONE de tu zona real, no con UTC ni con un offset fijo; con horario de verano el offset cambia y tus días se corren una hora dos veces al año. Dos: si tu dashboard tiene presets (Hoy, 7d, 30d, Todo), 'Todo' tiene que ser el histórico completo real. Un rango mayor jamás puede mostrar números menores que uno contenido en él; si pasa, tienes un bug de límites y la gente deja de confiar en el tablero.
08 antes de lanzar
Lo que reviso en cada landing nueva
- Cada bloque de la página tiene data-section con un nombre estable (hero, problema, metodo, precios, faq, registro). Si cambias el nombre después, pierdes la continuidad histórica.
- Los CTAs importantes tienen data-track con un nombre propio (cta-hero, cta-precios). Sin eso el tracker usa el texto del botón, y 'Quiero mi lugar' aparece tres veces sin saber cuál es cuál.
- El formulario llama getTracker().conversion() solo tras la respuesta exitosa del servidor, no al hacer clic en enviar.
- En el navegador, con la pestaña de red abierta: cambia de pestaña y verifica que sale un POST a /api/track con type exit. En iPhone, cambia de app y vuelve; debe existir el beacon.
- Abre la landing con ?utm_source=prueba&utm_campaign=%7B%7Bcampaign.name%7D%7D y confirma en la tabla que utm_campaign quedó vacío, no con la plantilla.
- Corre refresh_stats_daily() a mano y compara stats_daily contra un COUNT directo del mismo día. Tienen que coincidir.
FAQ lo que suelen preguntar
Preguntas frecuentes
¿Esto sustituye al pixel de la plataforma de anuncios?
No. El pixel (y su API de conversiones) es lo que la plataforma usa para optimizar la pauta; tu tracker es lo que tú usas para entender la página. Conviven. Lo que sí puedes hacer es cruzar los dos por sesión: guarda el fbclid o gclid en sessions y ya tienes el clic exacto.
¿Cuánto crece la tabla de eventos?
Una sesión promedio de 90 segundos genera entre 20 y 40 eventos (9 heartbeats, unas 10 secciones vistas y dejadas, 5 marcas de scroll, algunos clics). Con 50 mil sesiones al mes son alrededor de 1.5 millones de filas. Postgres lo aguanta sin drama con los índices del esquema; cuando pases de 10 millones, particiona track_events por mes y borra particiones viejas en vez de hacer DELETE.
¿Por qué lotes cada 3 segundos y no cada evento?
Porque cada POST es una invocación de función serverless y una transacción. Un scroll rápido genera decenas de eventos por segundo; en lotes, una sesión típica hace 8 a 12 requests en total. También es lo que permite que la ruta actualice los agregados de la sesión una vez por lote en vez de una vez por evento.
¿Qué pasa con visitantes que bloquean scripts o rechazan cookies?
El tracker no usa cookies ni dominios de terceros, así que los bloqueadores de rastreo comunes no lo tocan; es un fetch a tu propio dominio. Los que deshabilitan JavaScript por completo no aparecen, igual que en cualquier herramienta. Para cumplimiento, no guardas nada que identifique a la persona: el visitor_id es un UUID aleatorio que vive solo en su navegador.
¿Puedo usar la misma base para varias landings?
Sí, para eso está funnel_slug en todas las tablas. Cada despliegue lleva su FUNNEL_SLUG en variables de entorno y el dashboard filtra por él. Un proyecto de base de datos aguanta cómodamente diez o veinte landings.
Cierre de la guía
Un tracker propio no es un proyecto: es un archivo, una ruta y una migración que copias de landing en landing. Lo que sí es un proyecto es aprender a leer los datos: la cascada de alcance por sección, el scroll por fuente y la conversión con sus dos denominadores. Instálalo en la próxima landing antes de la primera campaña y compara lo que ves con lo que te decía GA. Esta guía vive en el Lab de David Iriza.
Fuentes oficiales4
- Navigator.sendBeacon() (MDN) ↗Firma, límite de ~64 KiB, tipos de datos aceptados y la recomendación de dispararlo en visibilitychange en lugar de unload.
- IntersectionObserver (MDN) ↗Opciones root, rootMargin y threshold, y la semántica de isIntersecting que usa el dwell por sección.
- Evento visibilitychange (MDN) ↗Por qué la transición a hidden es el último evento observable confiable, sobre todo en móvil.
- Request headers (Docs de Vercel) ↗Las cabeceras x-forwarded-for y x-vercel-ip-* que la ruta usa para IP y geolocalización, y la nota de que la ciudad llega codificada.
Sigue con estas guías
- Nº 006 · Escrituras seguras desde una landing →La ruta de tracking escribe con service role key desde el servidor; ahí está el patrón completo de escrituras seguras.
- Nº 011 · UTMs que sobreviven 30 días →El tracker captura los UTMs de la primera visita; esa guía los persiste para que lleguen al formulario aunque el visitante vuelva días después.
- Nº 010 · Píxel + CAPI sin duplicar →El CVR total de esta guía es el que se compara con la plataforma de anuncios; ahí se explica cómo se cuentan sus eventos.
Guía escrita con la información oficial disponible al 28 de agosto de 2026. Esta página no está afiliada a Vercel. Las herramientas cambian; ante la duda, revisa la documentación oficial de Vercel.
por David Iriza