Saltar al contenido

lección 5

Sesionizar y reconstruir usuarios desde eventos crudos

Agrupar eventos sueltos en sesiones con SQL (LAG + SUM acumulativo), entender el problema del usuario anónimo, y calcular las métricas de engagement que pide producto.

55 min

Imagina que sigues a alguien por la playa guiándote solo por las huellas que ha dejado en la arena. No lo ves a él: ves marcas sueltas, una detrás de otra. Pero si las lees en orden puedes reconstruir su paseo entero — por dónde entró, dónde se paró a mirar el mar, dónde dio media vuelta, dónde se sentó. Cada huella por sí sola no dice nada; la historia está en la secuencia. Los eventos que registra un producto digital son exactamente eso: huellas sueltas. "Usuario abrió la app", "pulsó un botón", "vio una pantalla", "cerró". Migas de pan que alguien fue dejando. El trabajo del analista es recoger esas migas y reconstruir con ellas un recorrido con sentido: cuándo empezó, qué hizo, cuándo terminó. A ese recorrido reconstruido lo llamamos sesión, y es la pieza que te falta para pasar de contar personas a entender comportamiento.

### Qué es una sesión y por qué la necesitas

Una sesión es un grupo de eventos consecutivos del mismo usuario, separados por un periodo de inactividad. La analogía más directa: piensa en las visitas a una tienda física. Un cliente puede entrar a las 10 de la mañana, mirar tres estantes y salir. Volver a las 5 de la tarde a comprar algo. Son dos visitas distintas del mismo cliente, aunque sea el mismo día. En digital es exactamente igual: un usuario puede abrir tu app a las 9, usar tres funciones y cerrarla. Volver a las 14 a hacer otra cosa. Son dos sesiones del mismo usuario.

El criterio estándar de la industria para separar sesiones es 30 minutos de inactividad. Si un usuario hace un evento a las 10:15 y el siguiente a las 10:20, es la misma sesión. Si el siguiente evento es a las 11:00 (40 minutos después), es una sesión nueva. Los 30 minutos no son mágicos — Google Analytics los usa desde 2005 y se han convertido en el estándar de facto. Algunas apps con sesiones muy cortas (juegos móviles) usan 5 minutos; otras con sesiones largas (herramientas de diseño) usan 60. Pero 30 es el punto de partida.

¿Por qué no basta con el user_id solo? Porque un usuario puede hacer tres sesiones en un día: una por la mañana, una después de comer y una por la noche. Si solo cuentas usuarios únicos, esas tres sesiones son "1". Pierdes la información de que ese usuario está muy enganchado (tres visitas en un día), y lo tratas igual que alguien que entró una vez treinta segundos. La sesión es la unidad de medida del engagement: más sesiones por usuario = más hábito; sesiones más largas = más profundidad; sesiones con una sola página vista = rebote.

Un usuario con tres sesiones en un día. Sin sesionizar, es un solo "usuario activo diario" que esconde tres niveles de engagement distintos.

Consejo de senior: cuando alguien dice "usuarios activos diarios" (DAU, Daily Active Users), pregunta siempre qué significa "activo". Para algunos es "abrió la app". Para otros es "hizo al menos una acción de valor" (crear una tarea, enviar un mensaje, completar una compra). La diferencia puede ser del 40%. Sin una definición compartida, el número no significa nada — y dos equipos con dos definiciones tendrán dos números distintos, ambos "correctos", y discutirán eternamente. Acláralo antes de calcularlo.

### El algoritmo de sesionización: LAG + SUM acumulativo

Sesionizar es el proceso de tomar una tabla de eventos crudos — donde cada fila es un evento suelto con su timestamp — y asignarle un session_id a cada uno. El resultado es que los eventos que pertenecen a la misma visita comparten el mismo identificador, y puedes agrupar por él para calcular duración, páginas vistas, acciones por sesión, y todo lo demás.

La técnica estándar usa dos window functions encadenadas. No es complicada si la descompones en pasos. La analogía: imagina que tienes una lista de llamadas telefónicas de un mismo cliente (hora de cada llamada). Quieres agruparlas en "conversaciones": si entre una llamada y la siguiente pasaron menos de 30 minutos, es la misma conversación (quizás se cortó y volvió a llamar). Si pasaron más de 30 minutos, es una conversación nueva. Eso es exactamente lo que vamos a hacer con SQL.

El proceso tiene tres pasos, y cada paso es una CTE que alimenta la siguiente:

  1. 01.Paso 1: calcular el gap — Con LAG, obtén el timestamp del evento anterior del mismo usuario. Resta: gap = timestamp_actual - timestamp_anterior. Si el gap es NULL (primer evento del usuario) o mayor de 30 minutos, este evento INICIA una sesión nueva.
  2. 02.Paso 2: marcar inicio de sesión — Crea un flag (1/0): 1 si el evento inicia sesión nueva, 0 si continúa la sesión anterior.
  3. 03.Paso 3: asignar session_id — Haz un SUM() acumulativo del flag, particionado por usuario. Cada vez que el flag es 1, la suma sube en 1 = nueva sesión. Mientras es 0, la suma no cambia = misma sesión.
Los tres pasos se encadenan en CTEs. El resultado es un session_num por usuario que identifica cada sesión.
1-- Sesionizacion completa: de eventos crudos a session_id
2WITH con_gap AS (
3 SELECT
4 *,
5 -- Paso 1: timestamp del evento anterior del mismo usuario
6 LAG(timestamp) OVER (
7 PARTITION BY user_id
8 ORDER BY timestamp
9 ) AS prev_ts
10 FROM events
11),
12con_flag AS (
13 SELECT
14 *,
15 -- Paso 2: es inicio de sesion nueva?
16 CASE
17 WHEN prev_ts IS NULL THEN 1 -- primer evento del usuario
18 WHEN DATEDIFF('minute', prev_ts, timestamp) > 30 THEN 1
19 ELSE 0
20 END AS nueva_sesion
21 FROM con_gap
22)
23-- Paso 3: acumular el flag para obtener el numero de sesion
24SELECT
25 user_id,
26 event_name,
27 timestamp,
28 SUM(nueva_sesion) OVER (
29 PARTITION BY user_id
30 ORDER BY timestamp
31 ROWS UNBOUNDED PRECEDING
32 ) AS session_num,
33 -- session_id legible: concatena user_id + numero
34 user_id || '-' || SUM(nueva_sesion) OVER (
35 PARTITION BY user_id
36 ORDER BY timestamp
37 ROWS UNBOUNDED PRECEDING
38 ) AS session_id
39FROM con_flag
40ORDER BY user_id, timestamp;

La query completa de sesionización con tres CTEs encadenadas

DATEDIFF en DuckDB devuelve la diferencia TRUNCADA entre dos timestamps en la unidad que le pidas. Si el gap real es 30 minutos y 50 segundos, DATEDIFF(minute, ...) devuelve 30, no 31 — porque trunca, no redondea. Con la condición > 30 ese caso NO abre sesión nueva (30 no es mayor que 30). Si tu umbral es "30 minutos o más", usa >= 30. Si es "más de 30 minutos estrictos", usa > 30. Parece un detalle, pero con millones de eventos las sesiones de borde son muchas, y la diferencia se ve en las métricas.

### El problema del usuario anónimo

Hasta aquí hemos asumido que cada evento tiene un user_id limpio. En la realidad, no es así. Piensa en lo que pasa cuando alguien visita tu web o app por primera vez: aún no se ha registrado, no tiene cuenta, no sabes quién es. Pero sí está generando eventos. La herramienta de tracking le asigna un identificador anónimo — normalmente basado en una cookie del navegador o un ID del dispositivo. Ese identificador temporal suele llamarse anonymous_id o device_id.

Cuando ese usuario finalmente se registra o hace login, pasa a tener un user_id real (el de tu base de datos de usuarios). El problema es que ahora tienes DOS identidades para la misma persona: los eventos de antes del registro tienen anonymous_id = "anon_abc123", y los de después tienen user_id = "usr_42". Si no los conectas, tu análisis dice que tienes dos usuarios distintos — uno que navegó mucho pero nunca convirtió, y otro que apareció de la nada y compró al instante. Ambas historias son falsas: son la misma persona.

La analogía: imagina una tienda física con un contador de personas en la puerta. Cuenta que alguien entró a las 10:15, miró cuatro estantes y se fue. A las 10:45 alguien entra, va directo al mostrador y dice "soy María García, vengo a recoger mi pedido". El contador no sabe que la persona de las 10:15 y María García son la misma. Pero si en el momento del recogido pudieras atar la visita anterior (la misma cara, la misma hora, la misma tienda) sabrías que María García tardó 30 minutos en decidirse. Sin esa unión, tienes una visita "anónima" que no convirtió y una "conocida" que convirtió instantáneamente. Las dos métricas mienten.

Sin identity stitching ves dos personas. Con él, ves una sola historia completa: desde la primera visita hasta la conversión.

### Identity stitching: cómo se hace en la práctica

Identity stitching (cosido de identidades) es el proceso de unificar los distintos identificadores de una misma persona real en un único perfil. El momento clave es el login o el registro: ahí el sistema sabe que "el dispositivo que generaba eventos como anon_abc123 pertenece a la persona usr_42". A partir de ese momento, todos los eventos anteriores de anon_abc123 se reatribuyen a usr_42.

En herramientas como Segment, Mixpanel o Amplitude, esto se hace con una llamada identify() que el desarrollador coloca en el momento del login. Esa llamada dice: "el anonymous_id X es en realidad el user_id Y". A partir de ahí, la herramienta fusiona ambos perfiles. Si tu empresa no usa una herramienta así y tú eres el analista que tiene que hacerlo en SQL, la técnica es más manual pero la lógica es la misma:

  1. 01.Construir una tabla de mapeo — Identifica los eventos donde aparecen AMBOS identificadores juntos (el signup_completed o login suele tener anonymous_id Y user_id en la misma fila). De ahí sacas un mapeo: anonymous_id -> user_id.
  2. 02.Aplicar el mapeo — Haz un LEFT JOIN de tu tabla de eventos contra ese mapeo. Si el evento tiene anonymous_id y el mapeo dice que ese anónimo es usr_42, sustituyes. Si no hay mapeo (nunca se registró), lo dejas como anónimo.
  3. 03.Crear un resolved_user_id — Un COALESCE(user_id_del_mapeo, anonymous_id) te da un identificador único: el user_id real si existe, el anónimo si no. Sesionizas sobre ESE campo.
1-- Identity stitching basico con SQL
2-- 1. Tabla de mapeo: extraer pares anonymous_id <-> user_id
3WITH identity_map AS (
4 SELECT DISTINCT
5 anonymous_id,
6 user_id
7 FROM events
8 WHERE user_id IS NOT NULL
9 AND anonymous_id IS NOT NULL
10)
11-- 2. Aplicar el mapeo a todos los eventos
12SELECT
13 e.event_name,
14 e.timestamp,
15 -- 3. Resolver: si hay user_id real, usalo; si no, el anonimo
16 COALESCE(im.user_id, e.anonymous_id) AS resolved_user_id,
17 e.anonymous_id AS id_original
18FROM events e
19LEFT JOIN identity_map im
20 ON e.anonymous_id = im.anonymous_id
21ORDER BY resolved_user_id, timestamp;

Identity stitching mínimo: mapeo + COALESCE para unificar identidades

Consejo de senior: esto te lo van a preguntar en la entrevista. "¿Cómo manejas los usuarios anónimos en tus análisis?" La respuesta correcta no es "ignoro los eventos sin user_id" (eso es tirar el 30-50% de tus datos). Es "construyo una tabla de mapeo con los eventos que tienen ambos identificadores, aplico un LEFT JOIN con COALESCE, y sesionizo sobre el resolved_user_id". Si además dices "y si un anónimo nunca se registra, lo cuento como usuario separado pero lo marco para no mezclarlo con los conocidos", demuestras criterio.

### Métricas que dependen de sesiones

Una vez que tienes sesiones asignadas, se desbloquean las métricas de engagement que producto necesita para entender si los usuarios están realmente usando el producto o solo pasando por ahí. Las cuatro métricas clásicas de sesión son:

  • Sesiones por usuario — Cuántas sesiones tiene cada usuario en un periodo. Un usuario con 15 sesiones al mes tiene un hábito establecido; uno con 1 sesión probablemente te está olvidando. Es la métrica de frecuencia.
  • Duración media de sesión — Cuánto tiempo pasa el usuario desde su primer evento hasta el último evento de esa sesión. Si la media es 2 minutos en una app de productividad, algo falla. Si es 45 minutos en un juego, va bien. Es la métrica de profundidad.
  • Bounce rate (tasa de rebote) — Porcentaje de sesiones que tienen un solo evento (normalmente un solo pageview). El usuario entró, vio una página y se fue sin hacer nada más. Es la métrica de primera impresión: un bounce rate alto sugiere que la gente llega y lo que ve no les convence para seguir.
  • Páginas por sesión (o eventos por sesión) — Cuántas acciones distintas hace un usuario por visita. En un ecommerce, más páginas por sesión suele correlacionar con más probabilidad de compra (el usuario está explorando). En una app de productividad, puede significar eficiencia (pocas acciones = resuelve rápido) o confusión (muchas acciones = no encuentra lo que busca). El contexto manda.
1-- Metricas de sesion a partir de eventos sesionizados
2WITH sesiones AS (
3 SELECT
4 user_id,
5 session_id,
6 MIN(timestamp) AS session_start,
7 MAX(timestamp) AS session_end,
8 COUNT(*) AS eventos_en_sesion,
9 DATEDIFF('minute', MIN(timestamp), MAX(timestamp)) AS duracion_min
10 FROM events_sesionizados -- tabla ya con session_id asignado
11 GROUP BY user_id, session_id
12)
13SELECT
14 -- Sesiones por usuario
15 COUNT(*) * 1.0 / COUNT(DISTINCT user_id) AS sesiones_por_usuario,
16 -- Duracion media
17 AVG(duracion_min) AS duracion_media_min,
18 -- Bounce rate
19 COUNT(*) FILTER (WHERE eventos_en_sesion = 1) * 100.0
20 / COUNT(*) AS bounce_rate_pct,
21 -- Eventos por sesion
22 AVG(eventos_en_sesion) AS eventos_por_sesion
23FROM sesiones;

Las cuatro métricas clásicas de sesión en una sola query

### La historia: de los logs del servidor a la sesión en tiempo real

El concepto de sesión web nació en los 90 con los logs de servidores Apache. Cada petición HTTP se registraba con la IP del visitante y la hora. Los primeros "web analytics" (Analog, Webalizer) agrupaban peticiones de la misma IP en "visitas" usando exactamente el mismo criterio: si pasaban más de 30 minutos entre dos peticiones, era una visita nueva. El umbral de 30 minutos viene de la época de las conexiones por módem, donde abandonar una página significaba literalmente colgar el teléfono.

Google Analytics hereda ese umbral en 2005 y lo convierte en estándar de la industria. Hoy, con apps que mandan heartbeats (señales de "sigo aquí" cada pocos segundos), la sesionización es más precisa — pero el concepto de fondo no ha cambiado en 30 años: agrupar actividad por periodos de silencio. Lo que sí cambió es DONDE se calcula: antes lo hacía tu herramienta de analytics por ti (Google Analytics te daba la sesión ya hecha); ahora, con datos crudos en tu propio almacén, eres tú quien la construye con SQL. Más trabajo, pero también más control: puedes elegir el umbral, excluir ciertos eventos, o sesionizar por dispositivo en vez de por usuario.

### Cuándo sesionizar y cuándo no hace falta

No siempre necesitas sesionizar. Si la pregunta de negocio es "cuántos usuarios compraron esta semana", no hace falta. Si es "cuántos eventos de tipo X hubo", tampoco. La sesionización entra cuando la pregunta involucra COMPORTAMIENTO DENTRO DE UNA VISITA: cuánto tiempo pasan, qué secuencia siguen, en qué punto abandonan, si vuelven el mismo día. Si la pregunta se puede responder con un COUNT + GROUP BY sin contexto temporal fino, no sesionices — es complejidad que no aporta.

Consejo de senior: muchas herramientas de producto (Mixpanel, Amplitude, Heap) sesonizan por ti de forma automática y te dan session_id ya calculado en los datos exportados. Si tu empresa usa una de estas y exporta a BigQuery o Snowflake, NO reinventes la sesionización: usa el session_id que ya viene. Solo constrúyela desde cero cuando trabajas con eventos crudos sin procesar (por ejemplo, de un sistema propio de tracking) o cuando necesitas un umbral distinto al que la herramienta usa por defecto.

### Juntando todo: el flujo completo del evento crudo a la métrica de engagement

Cada paso añade contexto: el stitching une personas, la sesionización agrupa comportamiento, y las métricas resumen la historia.

El orden importa. Primero resuelves identidades (stitching), DESPUÉS sesionizas. Si sesionizas antes de resolver, los eventos anónimos de una persona se sesionizarían separados de sus eventos autenticados, y tendrías sesiones partidas artificialmente en el momento del login. El stitching va siempre primero.

### Errores comunes al sesionizar

  • Olvidar el PARTITION BY user_id en el LAG — Sin la partición, LAG mira el evento anterior GLOBAL, no del mismo usuario. El resultado: el primer evento de usr_02 hereda el timestamp del último evento de usr_01, calcula un gap enorme y siempre abre sesión nueva. Las sesiones de todos los usuarios tendrán un solo evento. Es un error silencioso porque la query no falla — da resultados absurdos sin error.
  • Usar un umbral sin justificación — 30 minutos es el estándar, pero no es universal. Si tu app envía notificaciones push que reactivan al usuario a los 35 minutos, con umbral de 30 abres una sesión nueva cuando en realidad es una continuación. Mira la distribución de gaps de tu producto antes de elegir.
  • Contar duración de sesión con un solo evento — Una sesión de un solo pageview tiene duración = MAX(ts) - MIN(ts) = 0 minutos. No es que el usuario haya estado 0 segundos: es que no tienes más datos. Si incluyes esas sesiones en el AVG de duración, la media baja artificialmente. Decidir si se excluyen o se imputan un valor mínimo.
  • No deduplicar eventos — Eventos duplicados (el SDK de tracking mandó el mismo evento dos veces) inflan las sesiones y rompen el cálculo del gap. Deduplica por event_id ANTES de sesionizar.

### Resumen: lo que debes llevarte

  • Una sesión es un grupo de eventos consecutivos del mismo usuario separados por un umbral de inactividad (30 min por defecto).
  • Se calcula con LAG (gap entre eventos) + un flag de nueva sesión + SUM acumulativo del flag.
  • Antes de sesionizar, resuelve identidades: une anonymous_id con user_id usando una tabla de mapeo + COALESCE.
  • Las métricas de sesión (duración, bounce rate, frecuencia, páginas por sesión) son lo que mide el engagement real — no el simple conteo de usuarios únicos.
  • El sesionizado se hace sobre el resolved_user_id, nunca sobre el anonymous_id crudo ni sobre el user_id solo (perdería los eventos pre-login).

Regístrate para guardar tu progreso.

## comentarios

Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.

Inicia sesión para comentar y responder.

cargando comentarios...