La mayoría de desarrolladores acaba usando Google Analytics casi por reflejo. Es gratis, está en todas partes y probablemente ya tienes una cuenta. Pero si estás usando Rails, ya tienes una base de datos, un servidor y un framework muy bueno para construir cosas. ¿Por qué entregar los datos de tus visitantes a Google si puedes ser dueño de cada línea del sistema de analítica?
Construí un dashboard completo para este blog: visitantes únicos, páginas vistas, tasa de rebote, duración de visitas, tendencias de tráfico con gráficos SVG, geolocalización por país, desglose de referidos y comparación entre periodos. Es público: puedes verlo en alvareznavarro.es/analytics. Cada métrica, cada gráfico y cada tabla. Sin scripts de terceros, sin banners de cookies y sin sacar datos del servidor.
Así es como lo construí con Claude Code como compañero de programación con IA.
Por qué construir tu propia analítica
Hay cuatro razones, en orden de importancia.
Privacidad. No hay JavaScript de terceros siguiendo a tus visitantes por la web. No hay banners de consentimiento para cookies. No hay más dolores de cabeza de GDPR que los que ya tendrías. Los datos de tus visitantes se quedan en tu base de datos, en tu servidor.
Propiedad. Si Google cambia su dashboard, retira una métrica o jubila Universal Analytics otra vez, no te afecta. Tus datos, tus consultas, tus reglas.
Personalización. Google Analytics te enseña lo que Google cree que importa. Yo quería ver las métricas relevantes para un blog personal: páginas principales, dominios de referencia clasificados por tipo (social, búsqueda, otros), indicadores de comparación entre periodos y un gráfico SVG limpio de tendencia. Sin análisis de embudos, sin ecommerce, sin ruido.
Simplicidad. Una gema para registrar visitas. Un controlador para las consultas. Una vista para mostrar los datos. Ese es todo el stack de analítica. Compáralo con aprender el modelo de eventos de GA4, Tag Manager, flujos de datos y configuración de propiedades.
Alternativas como Plausible son buenos productos. Pero cuestan dinero, corren en infraestructura de otro y siguen implicando que no eres dueño del código. Para un blog personal, construirlo tú mismo lleva menos tiempo que evaluar opciones SaaS.
Las herramientas
Tres piezas hacen que esto funcione.
Ahoy Matey se encarga del seguimiento de páginas vistas. Es una gema de Rails que registra visitas y eventos en el servidor, sin tracker JavaScript y sin cookies. Crea dos tablas (ahoy_visits y ahoy_events) y te da modelos Active Record para consultarlas. Configurada sin cookies, tienes analítica respetuosa con la privacidad y cero código en el cliente.
Geocoder + MaxMindDB resuelven la geolocalización por IP. Geocoder es la gema estándar de Ruby para geocodificación. Combinada con un archivo local de MaxMind GeoLite2-City, cada búsqueda de IP ocurre en tu servidor: sin llamadas externas, sin límites de API y sin latencia añadida. Ahoy se integra automáticamente con Geocoder si se lo indicas.
Claude Code + Ariadna fue la forma en que lo construí. Claude Code es la herramienta CLI de Anthropic para programación asistida por IA: describes lo que quieres, lee tu código y escribe los cambios. Ariadna es un plugin para Claude Code que añade planificación estructurada. En lugar de saltar directamente de la idea al código, Ariadna introduce un flujo: escribir una especificación de diseño que describe qué construir y por qué, generar un plan de implementación con tareas concretas, rutas de archivos y mensajes de commit, y ejecutar tarea por tarea con commits atómicos. Es una capa de planificación entre "quiero analítica" y "aquí está el código".
El flujo de Ariadna para el dashboard fue así:
- Especificación de diseño. Describí el dashboard que quería: tarjetas KPI, gráficos SVG, comparación entre periodos y geolocalización. Especifiqué Ahoy para el tracking, nada de librerías JavaScript para gráficos y acceso público sin autenticación.
- Plan de implementación. Ariadna dividió la especificación en tareas ordenadas: instalar y configurar Ahoy, crear el concern
Trackable, construir el controlador de analítica con cálculos de métricas, crear la vista con gráficos SVG, añadir geolocalización y escribir tests. - Ejecución. Claude Code siguió el plan tarea a tarea. Revisé cada commit antes de pasar al siguiente. Si algo necesitaba ajuste, se lo decía y Claude Code lo corregía.
La especificación tenía unas 200 líneas. El plan de implementación, más de 800. El código resultante quedó limpio, probado y publicado en una sola sesión.
Registrar páginas vistas automáticamente
Lo primero que necesitas es una forma de registrar cada página vista sin tocar cada acción de cada controlador. Un concern de Rails encaja perfectamente.
# app/controllers/concerns/trackable.rb
module Trackable
extend ActiveSupport::Concern
included do
after_action :track_page_view
end
private
def track_page_view
return unless trackable_request?
ahoy.track "Page View", url: request.path
end
def trackable_request?
request.get? &&
request.format.html? &&
response.successful? &&
!request.path.start_with?("/admin", "/login", "/session", "/up", "/mcp")
end
end
Incluyes esto en ApplicationController y cada petición pública queda registrada automáticamente. El método trackable_request? filtra el ruido: páginas de administración, intentos de login, health checks y endpoints de API. Solo se registran peticiones GET exitosas de páginas HTML.
La configuración de Ahoy es igual de mínima:
# config/initializers/ahoy.rb
class Ahoy::Store < Ahoy::DatabaseStore
end
Ahoy.api = false
Ahoy.cookies = :none
Ahoy.server_side_visits = :when_needed
Ahoy.visit_duration = 30.minutes
Las tres líneas importantes son estas: api = false desactiva la API JavaScript, porque no la necesitamos; cookies = :none evita escribir cookies en el navegador; y server_side_visits = :when_needed crea las visitas en el servidor. Cero JavaScript. Cero cookies. Cada página vista se registra con un único after_action.
El controlador de analítica
El controlador toma los datos brutos de Ahoy y calcula las métricas que esperarías en cualquier dashboard de analítica.
Empieza determinando el periodo y consultando tanto el periodo actual como el anterior:
def index
@period = params[:period] || "30d"
@since = period_start(@period)
@previous_since = previous_period_start(@period)
visits = Ahoy::Visit.where("started_at >= ?", @since)
previous_visits = Ahoy::Visit.where(
"started_at >= ? AND started_at < ?", @previous_since, @since
)
events = Ahoy::Event.where("time >= ?", @since)
.where(name: "Page View")
# ...
end
Hay cuatro periodos disponibles: hoy, siete días, 30 días y 12 meses. Cada uno consulta también el periodo equivalente anterior para poder comparar. La vista de siete días compara con los siete días previos; la de 30 días, con los 30 días previos.
Visitantes únicos es directo: Ahoy asigna a cada visitante un visitor_token basado en su IP y user agent.
@unique_visitors = visits.distinct.count(:visitor_token)
Tasa de rebote requiere agrupar eventos por visita y contar cuántas visitas tuvieron una sola página vista:
def compute_bounce_rate(events_scope)
page_view_counts = events_scope.group(:visit_id).count
visits_with_views = page_view_counts.size
return 0 if visits_with_views == 0
bounced = page_view_counts.count { |_, count| count == 1 }
(bounced.to_f / visits_with_views * 100).round(0)
end
Un rebote es una visita en la que el usuario vio exactamente una página y se fue. El concepto es simple, pero la consulta importa. Agrupamos todos los eventos de página vista por visit_id, los contamos y calculamos qué porcentaje de visitas tuvo exactamente un evento.
Duración de visita es más delicada. Solo puedes calcular duración en visitas donde el visitante vio al menos dos páginas: las visitas de una sola página no tienen un segundo timestamp contra el que medir. Este cálculo usa strftime de SQLite para obtener la diferencia en segundos entre el primer y el último evento:
def compute_avg_duration(events_scope)
durations = events_scope
.group(:visit_id)
.having("COUNT(*) >= 2")
.pluck(Arel.sql(
"CAST(strftime('%s', MAX(time)) - strftime('%s', MIN(time)) AS INTEGER)"
))
return 0 if durations.empty?
(durations.sum / durations.size.to_f).round(0)
end
La cláusula HAVING("COUNT(*) >= 2") es crítica. Sin ella, incluirías visitas de una sola página como entradas de duración cero y hundirías artificialmente la media.
Comparación entre periodos calcula el cambio porcentual y marca si el cambio es positivo o negativo, con un caso especial para la tasa de rebote, donde bajar es bueno:
def percentage_change(current, previous, inverted: false)
return nil if previous == 0 && current == 0
return :new if previous == 0
change = ((current - previous) / previous.to_f * 100).round(0)
{ value: change, positive: inverted ? change <= 0 : change >= 0 }
end
El flag inverted: true para la tasa de rebote hace que, cuando baja, el indicador aparezca en verde y no en rojo. Es un detalle pequeño, pero evita confundirte cada vez que miras el dashboard.
Los helpers que muestran estas métricas en la vista son igual de directos:
# app/helpers/analytics_helper.rb
module AnalyticsHelper
def format_duration(seconds)
seconds = seconds.to_i
return "0s" if seconds <= 0
hours = seconds / 3600
minutes = (seconds % 3600) / 60
secs = seconds % 60
parts = []
parts << "#{hours}h" if hours > 0
parts << "#{minutes}m" if minutes > 0
parts << "#{secs}s" if secs > 0 || parts.empty?
parts.join(" ")
end
def change_badge(change)
return "" if change.nil?
if change == :new
tag.span("New", class: "analytics-kpi-change analytics-kpi-change--new")
else
css = change[:positive] ? "analytics-kpi-change--up" : "analytics-kpi-change--down"
arrow = change[:positive] ? "↗" : "↘"
tag.span("#{arrow} #{change[:value].abs}%",
class: "analytics-kpi-change #{css}")
end
end
end
format_duration convierte segundos en una cadena legible: 2m 30s en lugar de 150. change_badge renderiza el indicador de comparación con flecha y color.
Visualizaciones con SVG puro
Tomé una decisión deliberada: nada de Chart.js, nada de D3, nada de librerías JavaScript de gráficos. El gráfico de tendencia de tráfico se renderiza por completo con ERB y SVG. Una dependencia menos, funciona sin JavaScript y el código es sorprendentemente sencillo.
La vista calcula coordenadas SVG a partir de los datos y dibuja una línea con relleno degradado:
<%
points = @trend_data.to_a
max_val = points.map(&:last).max.to_f
max_val = 1.0 if max_val == 0
chart_w = 800
chart_h = 200
usable_w = chart_w
usable_h = chart_h - 40 # padding
coords = points.each_with_index.map do |(_, val), i|
x = i * (usable_w.to_f / (points.size - 1))
y = 20 + usable_h - (val / max_val * usable_h)
[x.round(1), y.round(1)]
end
polyline = coords.map { |x, y| "#{x},#{y}" }.join(" ")
area = "#{coords.first[0]},#{chart_h} #{polyline} #{coords.last[0]},#{chart_h}"
%>
<svg viewBox="0 0 800 200" preserveAspectRatio="none">
<polygon points="<%= area %>" fill="url(#areaGradient)" />
<polyline points="<%= polyline %>" fill="none"
stroke="var(--accent)" stroke-width="2.5"
stroke-linejoin="round" stroke-linecap="round" />
<% coords.each do |x, y| %>
<circle cx="<%= x %>" cy="<%= y %>" r="3"
fill="var(--accent)" stroke="var(--surface)" stroke-width="1.5" />
<% end %>
<defs>
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--accent)" stop-opacity="0.2" />
<stop offset="100%" stop-color="var(--accent)" stop-opacity="0.02" />
</linearGradient>
</defs>
</svg>
La matemática es básica: mapear cada punto a una coordenada x,y dentro del viewBox, unir esos puntos en una cadena para polyline y crear un polygon para el área con degradado. Las líneas de la rejilla son elementos <line> discontinuos. Los puntos son elementos <circle>. Todo el gráfico responde a propiedades CSS (var(--accent), var(--surface)), así que funciona automáticamente con tema claro y oscuro.
Para datos tabulares, como páginas principales, referidos y países, uso barras en línea. Cada fila tiene una barra de fondo cuyo ancho es proporcional al valor máximo:
<% @top_pages.each do |page, count| %>
<tr>
<td>
<div class="analytics-table-bar-cell">
<div class="analytics-table-bar"
style="width: <%= (count / max_page * 100).round(1) %>%">
</div>
<span><%= truncate(page, length: 60) %></span>
</div>
</td>
<td class="analytics-table-num"><%= number_with_delimiter(count) %></td>
</tr>
<% end %>
La barra es solo un div con un ancho en porcentaje y un color de fondo translúcido. El texto queda encima. Consigues el impacto visual de un gráfico de barras dentro de una tabla, sin JavaScript.
Añadir geolocalización
Saber de qué países vienen tus visitantes es útil y no exige una API externa. MaxMind publica una base GeoLite2 gratuita que mapea direcciones IP a países y ciudades. Descargas el archivo .mmdb, lo colocas en el proyecto y configuras Geocoder para usarlo:
# config/initializers/ahoy.rb (geolocation section)
mmdb_path = Rails.root.join("db/maxmind/GeoLite2-City.mmdb")
if mmdb_path.exist?
Geocoder.configure(
ip_lookup: :geoip2,
geoip2: { file: mmdb_path }
)
Ahoy.geocode = true
else
Ahoy.geocode = false
end
El condicional permite que la aplicación funcione sin el archivo de MaxMind: simplemente se salta la geolocalización. Es importante para entornos de desarrollo y CI donde quizá no tengas esa base de datos.
Cuando Ahoy registra una visita, Geocoder busca la IP en la base local de MaxMind y rellena el campo country. La consulta del controlador cabe en una línea:
@top_countries = visits.where.not(country: [nil, ""])
.group(:country).order(Arel.sql("count(*) DESC")).limit(20).count
Tests
El dashboard tiene 11 tests de integración que cubren todo el rango de funcionalidad. El archivo de tests usa dos helpers que crean registros de Ahoy directamente:
def create_visit(visitor_token: SecureRandom.hex,
started_at: Time.current, **attrs)
Ahoy::Visit.create!(
visit_token: SecureRandom.hex,
visitor_token: visitor_token,
started_at: started_at,
**attrs
)
end
def create_event(visit:, time: Time.current, url: "/blog")
Ahoy::Event.create!(
visit: visit,
name: "Page View",
properties: { "url" => url },
time: time
)
end
Estos helpers permiten montar escenarios precisos. Por ejemplo, para probar la tasa de rebote:
test "calculates bounce rate from single-page visits" do
v1 = create_visit(started_at: 1.day.ago)
create_event(visit: v1, time: 1.day.ago)
v2 = create_visit(started_at: 1.day.ago)
create_event(visit: v2, time: 1.day.ago)
create_event(visit: v2, time: 1.day.ago + 5.minutes, url: "/about")
get analytics_url
assert_response :success
assert_select ".analytics-kpi-card .analytics-kpi-value", text: "50%"
end
Una visita con una página vista, otra visita con dos páginas vistas. Tasa de rebote esperada: 50%. El test comprueba que el HTML renderizado contiene exactamente ese valor. Otros tests cubren deduplicación de visitantes únicos, cálculo de duración, estado sin datos, selección de periodo, países e indicadores de comparación.
El flujo con Claude Code que lo hizo rápido
Aquí conviene ser específico sobre qué significa "programación en pareja con IA" en la práctica, porque muchas descripciones se quedan en lo abstracto.
Claude Code es una herramienta CLI. La ejecutas en la terminal, tiene acceso a los archivos del proyecto y mantienes una conversación sobre lo que quieres construir. Lee tu código, propone cambios, escribe archivos y ejecuta tests. Es como un compañero muy capaz que escribe rápido, pero necesita dirección.
Ariadna añade estructura encima de Claude Code. Sin Ariadna, una sesión típica sería: "construye analítica". Claude Code empezaría a escribir código, tú irías revisando y corrigiendo el rumbo. Para tareas pequeñas funciona bien. Para algo más grande, la conversación puede dispersarse y el código acaba reflejando esa dispersión.
Con Ariadna, el flujo tiene fases explícitas.
Especificación de diseño. Describes qué quieres construir, por qué lo construyes y las decisiones arquitectónicas clave. Para el dashboard de analítica, la especificación cubría: qué gema usar para tracking (Ahoy), cómo resolver geolocalización (archivo local de MaxMind), qué métricas calcular (visitantes únicos, tasa de rebote, duración, comparación entre periodos), cómo renderizar gráficos (SVG puro, sin librerías JavaScript) y control de acceso (público, sin autenticación). La especificación es un documento, no una conversación: se escribe en un archivo que se convierte en la fuente de verdad del trabajo.
Plan de implementación. Ariadna genera una lista ordenada de tareas a partir de la especificación. Cada tarea indica qué archivos crear o modificar, qué debe conseguir el código y un mensaje de commit ya preparado. El plan del dashboard tenía tareas como: "instalar y configurar Ahoy con tracking sin cookies", "crear el concern Trackable con filtrado inteligente de peticiones", "construir AnalyticsController con métodos de cálculo de KPIs", "añadir generación de gráficos SVG a la vista de analítica", "configurar geolocalización con MaxMind" y "escribir tests de integración". Cada tarea incluía rutas de archivos, enfoque y criterios de éxito.
Ejecución. Claude Code sigue el plan tarea a tarea. Escribe el código, ejecuta los tests y crea un commit. Revisas cada commit antes de empezar el siguiente. Si algo necesita ajuste, como "ese cálculo de rebote está mal" o "quiero que el gráfico use propiedades CSS", se lo dices, Claude Code lo corrige y el commit se enmienda antes de avanzar.
Hacerlo público
La mayoría de dashboards de analítica viven detrás de un login. Hice público el mío porque la analítica transparente genera confianza. Si alguien lee un artículo y se pregunta "¿esto lo lee alguien?", puede comprobarlo.
No hay datos sensibles en la página, solo métricas agregadas. Las IPs y tokens de visitantes individuales nunca aparecen en la vista. Lo peor que alguien puede aprender de mi analítica pública es qué páginas son populares y de qué países vienen los lectores.
Lo que aprendí
Construir tu propio dashboard de analítica lleva menos tiempo del que parece. La gema Ahoy resuelve las partes difíciles: seguimiento de visitas, identificación de visitantes y almacenamiento de eventos. Lo que queda es un controlador que ejecuta consultas y una vista que muestra resultados.
El tamaño total: un sistema de analítica completo en menos de 600 líneas de código, incluyendo vistas. Cada línea se puede leer, cada consulta se entiende y cada métrica se calcula exactamente como quiero.
Usar Claude Code con el flujo de planificación de Ariadna hizo que el código saliera limpio en la primera pasada. La especificación me obligó a tomar decisiones arquitectónicas antes de escribir: tracking sin cookies, gráficos SVG puros, comparación invertida para tasa de rebote. El plan le dio a Claude Code un camino claro, lo que significó menos correcciones de rumbo y más tiempo revisando código en lugar de escribir prompts.
El stack es simple: Rails + Ahoy + Geocoder + MaxMind + propiedades CSS + ERB. Sin librería JavaScript de analítica, sin framework de gráficos y sin servicio externo de tracking. Todo corre en el mismo servidor que el blog.
Mira la analítica en alvareznavarro.es/analytics. Luego construye la tuya. Si estás usando Rails, ya tienes casi todo lo que necesitas.