Saltar al contenido

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.

Publicada 28 de agosto de 2026Lectura 26 minNecesitas una landing publicada

De un vistazo

01

Un script tuyo mide qué sección leen y dónde se van

02

Los eventos viajan en lotes a tu propia tabla

03

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.

Dwell por sección

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.

Scroll máximo

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.

Conversión con contexto

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.

Datos crudos tuyos

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

  1. El tracker arranca en el cliente, arma el contexto de la sesión (ids, dispositivo, UTMs, referrer) y manda un session_init.
  2. 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.
  3. Un heartbeat cada 10 segundos acumula tiempo visible. Si la pestaña se oculta, el heartbeat se pausa; si vuelve, se reanuda.
  4. 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.
  5. 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.
  6. 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).
  7. Un cron refresca el rollup diario. El dashboard solo lee ese rollup, nunca suma la tabla de eventos.
EventoCuándo se disparaCampo clave
session_initAl cargar, una vez por sesiónmeta.ctx con todo el contexto
section_viewUna sección [data-section] cubre 50% del viewportsection_id
section_leaveEsa sección deja de cubrir el 50%section_id, dwell_ms
scrollAl cruzar 25, 50, 75, 90 o 100%scroll_pct
heartbeatCada 10 s con la pestaña visibledwell_ms del intervalo
clickClic en a, button o [data-track]target, value (href), section_id
form_focusPrimer foco en un input del formulariotarget (name del campo)
conversionLo llamas tú tras el submit exitosometa libre
exitPestaña oculta o cerradascroll_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".

Atajo: clonar el repo de esta guía (terminal)
git clone https://github.com/davidiriza-lab/analytics-propio-landing.git
Repo público con licencia MIT: github.com/davidiriza-lab/analytics-propio-landing. Trae los archivos de abajo listos para copiar a tu proyecto.
lib/tracker.ts
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:

components/TrackerMount.tsx
"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.

app/api/track/route.ts
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 });
}
Los bots se marcan, no se descartan

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.

El saneado de UTMs no es opcional

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.

Geo desde las cabeceras

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.

ignoreDuplicates en el upsert

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.

sql/schema.sql
-- 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

SQL: mapa de calor de secciones
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

SQL: hasta dónde baja cada 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.

SQL: CVR de calidad y CVR comparable
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

SQL: qué anuncio trae gente que convierte
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.

Días cerrados: congelados

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.

Solo hoy: en vivo

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.

El cron mantiene la cola

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.

Reconciliar antes de confiar

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.

Cron en Postgres con pg_cron (extensión disponible en Supabase)
SELECT cron.schedule('refresh-stats-daily', '*/15 * * * *', $$SELECT refresh_stats_daily(4, 'America/Mexico_City')$$);
Si no quieres pg_cron, un Vercel Cron que llame a una ruta protegida por secreto y ejecute el RPC hace lo mismo.

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

  1. 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.
  2. 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.
  3. El formulario llama getTracker().conversion() solo tras la respuesta exitosa del servidor, no al hacer clic en enviar.
  4. 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.
  5. 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.
  6. 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

Sigue con estas guías

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.