Todos los proyectos
Open Source · v0.1.1Proyecto 07
AnalyticsSelf-hostedOpen Source

Traccia

Un binario que sirve el script de tracking, la API, el dashboard y el panel de admin. Los plugins son JavaScript que corre dentro del binario: mutan o descartan eventos antes de guardarse y declaran paneles que el servidor renderiza. Ni una línea de ese JS llega al navegador.

GoPostgreSQLHTMXgojaHexagonalMIT

Los plugins de Traccia se escriben en JavaScript. Ni una línea llega al navegador.

Analytics web self-hosted en un binario Go y un Postgres. Para extenderlo dejas un .js en una carpeta: corre dentro del binario, engancha cada evento antes de guardarse y declara paneles que el dashboard pinta en el servidor. Sin Node, sin fork, sin proceso aparte.

navegador
t.js · tracking
5 KB
Se sirve desde el propio binario. Sin minificar, sin build.
navegador
htmx + uPlot · dashboard
99 KB
Vendoreados y embebidos. Sin CDN.
servidor · en el binario
Plugins .js
0 bytes
Corren en goja, un intérprete JS en Go puro. Nada llega al cliente.
100 mspresupuesto por plugin y evento
8ports: interfaces con adapter intercambiable
151tests, 17 contra un Postgres real en CI
01 · Cómo funciona

Un evento viaja de tu página al dashboard en un solo proceso

Pulsa los pasos o deja que avance. Cada paso ilumina la pieza de la que habla.

Tu web
t.js · 5 KB
Ingesta
/api/v1/track
Plugins
onEvent · 100 ms
Postgres
único estado
Dashboard
HTMX · paneles
Paso 1 de 5 · avanza solo
Una etiqueta script en tu página
El binario sirve su propio script de tracking en /t.js: 5 KB sin minificar, el mismo fichero que el paquete npm. Cuenta pageviews solos, incluidos los cambios de ruta de una SPA, y expone traccia.track(nombre, metadata).

La instalación mínima: una etiqueta y, si quieres, un atributo en el botón que te interesa.

<script src="https://analytics.tu-dominio.com/t.js"
data-project="<project_id>" defer></script>
<button data-traccia-event="signup_clicked">Crear cuenta</button>
# o desde JS · ejemplo
traccia.track("calculator_used", { from_currency: "USD" })
02 · Puertos y adaptadores

El sistema de plugins entero entró sin tocar un caso de uso

Hexagonal de verdad tiene una prueba sencilla: la pieza más invasiva del producto se enchufa desde fuera. Aquí es un decorador del port EventRepository.

Entran · adaptadores de entrada
httpapi
REST /api/v1 · sirve /t.js
sin lógica de negocio
dashboard
HTMX + plantillas Go
embed.FS
admin
cuentas, proyectos, saltar al dashboard
Núcleo
domain + usecase
domain importa solo time · 17 casos de uso
EventRepositoryProjectRepositoryVisitorRepositoryAdminUserRepositoryUserAgentParserGeoResolverAPIKeyHasherPasswordHasher
Salen · adaptadores de salida
plugins
decorador de EventRepository: corre onEvent y delega el Save
goja
postgres
events · visitors · projects · admin_users · plugin_kv
único adapter de storage
geoip · useragent
MaxMind local o no-op · parser heurístico
apikey · password
SHA-256 para tokens · bcrypt para contraseñas
Los casos de uso dependen de ports.EventRepository y no saben que los plugins existen. Cambiar el storage es implementar la interfaz, cablearla en cmd/api y recompilar; hoy solo existe el adapter de Postgres, SQLite y ClickHouse están en el roadmap.

Así se ve un plugin: un fichero, dos ganchos. Uno corre en cada evento, el otro una vez al arrancar.

// plugins/calculadora.js
function onEvent(event) {
if (event.type === "error") {
http.post(WEBHOOK, { text: event.name });
}
return event; // null lo descarta
}
function registerPanel() {
return { title: "Uso por moneda",
eventName: "calculator_used",
groupBy: "from_currency" };
}
onEvent · en cada evento, antes de guardarse
return eventse guarda, con los cambios que hizo el plugin
return nullse descarta: nunca toca Postgres
error o > 100 msse guarda sin cambios y se registra un aviso
registerPanel · una vez, al arrancar
Uso por moneda
USD312
VES187
EUR41
Tabla que el servidor renderiza en el dashboard con metadata->>'from_currency'. Cifras de ejemplo.
Ports en Go
Cambiar storage, GeoIP o parser de user-agent implementando una interfaz. Se recompila.
Plugins JS
Mutar o descartar eventos, disparar webhooks, declarar paneles. Un fichero y un reinicio.
Un solo estado
Todo vive en Postgres. Migraciones SQL planas en el primer arranque, ningún job de fondo después.
03 · Un ejemplo de punta a punta

De una calculadora de divisas a un panel propio

Un caso inventado con números pequeños, para ver qué pasa en cada tramo antes de hablar de los retos.

1
lunes · 09:00
Lucía instala el script
Tiene una calculadora de divisas en su web. Crea el proyecto en /admin, copia el project_id a la etiqueta script y guarda la api_key que solo verá una vez. Los pageviews empiezan a contarse solos.
2
lunes · 09:40
Un evento con metadata
Un visitante convierte 100 USD a bolívares. Su código llama traccia.track("calculator_used", { from_currency: "USD" }). El servidor guarda la IP como 203.0.113.0, nunca la real.
3
martes
Un plugin de doce líneas
Deja calculadora.js en la carpeta de plugins y reinicia. registerPanel declara "Uso por moneda" agrupado por from_currency. El dashboard ya muestra la tabla: USD 312, VES 187, EUR 41 en la semana.
4
jueves · 15:12
Un error en producción
Su código llama traccia.trackError("rate_unavailable"). El onEvent del mismo plugin lo ve antes de guardarse y hace http.post al webhook de Slack. El evento se guarda igual; el aviso llega en segundos.
5
viernes
Un plugin roto no pierde nada
Edita el plugin y deja un bucle infinito por error. A los 100 ms el runtime lo interrumpe, el evento se guarda sin cambios y el log lo dice. El tráfico real no se enteró.
04 · Los retos

Cinco veces en que la solución obvia estaba mal

Cada reto sigue el mismo patrón: lo que cualquiera haría primero, por qué no sirve, lo que se hizo y el número que lo demuestra. El detalle técnico queda plegado.

01

Plugins sin Node, sin WASM y sin proceso aparte

Lo obvio
Un plugin-server en Node al lado del binario, o WASM, o un directorio de binarios Go que se cargan al arrancar.
Por qué falla
Cada opción rompe la promesa de "un binario": otra imagen que mantener, otro proceso que vigilar, o una toolchain que el usuario tiene que instalar para escribir diez líneas. Y un plugin colgado no puede convertirse en tráfico perdido.
Todo lo que un plugin puede tocar
✓ log.info / warn / error
✓ http.post(url, body)
✓ kv.get(key) / kv.set(key, valor)
3 objetos, 6 funciones. El webhook tiene 5 s de timeout y no devuelve nada al script.
Lo que no existe
✗ require / import
✗ sistema de ficheros
✗ fetch o red arbitraria
✗ respuesta del webhook
✗ kv de otro plugin
El sandbox es la frontera de seguridad, no una sugerencia.
Lo que se hizo
Cada .js corre en su propio runtime goja, JavaScript interpretado en Go puro. El sandbox son tres objetos: log, http.post y kv. Cada llamada a onEvent tiene 100 ms; al pasarse, el runtime se interrumpe y el evento se guarda sin cambios. Un plugin con error de sintaxis se salta con una línea de log, no impide el arranque.
13 testsejecutan JavaScript real, incluido el bucle infinito que se interrumpe y deja el runtime reutilizable.
Ver detalle técnico

La integración con el resto del sistema es un solo punto: EventRepositoryDecorator envuelve el repositorio real y corre los plugins antes de Save. El primer plugin que devuelve null descarta el evento; los siguientes no pueden resucitarlo. El orden es alfabético por nombre de fichero.

Los runtimes de goja no son seguros para uso concurrente, así que cada plugin lleva un mutex: las peticiones que pegan al mismo plugin se encolan, plugins distintos corren en paralelo. No es una ruta para ingesta de alto throughput con trabajo pesado por evento; está escrito como límite de diseño en docs/plugins.md.

El kv persiste en una tabla plugin_kv con espacio de nombres por plugin. El webhook es fire-and-forget con 5 s de timeout y no expone la respuesta al script.

02

Un dashboard que los plugins extienden sin frontend

Lo obvio
Una SPA en React con gráficos bonitos y un sistema de widgets.
Por qué falla
Para que un plugin añadiera un panel tendría que enviar componentes de frontend: toolchain, bundler y module federation, el camino de Grafana. Y el navegador ejecutaría código de terceros que el servidor no puede acotar.
Dashboard SPA
1El plugin escribe un componente de frontend
2Toolchain y bundler para compilarlo
3Module federation para cargarlo en caliente
4El navegador ejecuta código de terceros
El camino de Grafana. Correcto a su escala, un fork a la de este proyecto.
Traccia
1El plugin devuelve un objeto: título, evento, groupBy
2El servidor lo convierte en un struct Panel
3Una plantilla Go lo pinta como HTML
4HTMX lo inserta en la página
Cero build step. El plugin nunca envía JavaScript al navegador.
Lo que se hizo
registerPanel() devuelve un objeto con título, evento y groupBy. El servidor lo convierte en un struct y una plantilla Go lo pinta. El groupBy es real: agrupa las ocurrencias del evento por el valor de esa clave del metadata en el rango elegido, y los eventos sin la clave se excluyen en vez de caer en un bucket vacío. htmx y uPlot van vendoreados y embebidos con embed.FS.
0 KBde JavaScript de plugin en el navegador. Sin CDN, sin build step de frontend.
Ver detalle técnico

La sesión del dashboard es una cookie firmada con HMAC, sin almacén en servidor: nada que limpiar y nada que perder al reiniciar salvo los logins activos. Un design system compartido (/assets/theme.css) vino después, para que dashboard y admin dejaran de ser dos pieles distintas.

Hoy todo panel se renderiza como tabla nombre/conteo. chart: "line" | "bars" se acepta en el spec pero solo se captura. Está en el roadmap del README, no escondido.
03

Tres credenciales, tres niveles de confianza

Lo obvio
Una sola API key que sirve para enviar eventos y para leer las estadísticas.
Por qué falla
Esa key iría en la etiqueta script, y cualquiera que lea el código fuente de la página podría leer las stats de todo el proyecto. El diseño inicial mezclaba las dos cosas y hubo que separarlas. Y para el admin, pegar un token hex de 64 caracteres en un formulario nunca fue un login humano.
project_id
público
Va en la etiqueta script. Solo sirve para escribir eventos. La defensa es el límite por IP, no el secreto.
api_key
lectura de un proyecto
Se muestra una vez al crear el proyecto. Lee las estadísticas de ese proyecto y de ningún otro.
cuenta admin
todo
Usuario y contraseña con bcrypt. Crea, lista y borra proyectos. Separada del token de máquina de la API.
Lo que se hizo
El project_id es público por diseño, como el measurement ID de cualquier analytics: solo escribe, y la defensa es el límite por IP. La api_key se muestra una vez y solo lee su proyecto. La cuenta de admin es usuario y contraseña con bcrypt, con setup de una sola vez en la primera visita a /admin y sin registro abierto después.
10 / minpor IP en los tres logins. Es lo único entre una api_key y un ataque por fuerza bruta. La ingesta admite 120 / min.
Ver detalle técnico

Dos hashers distintos a propósito: SHA-256 para las API keys, que son tokens aleatorios de alta entropía, y bcrypt para contraseñas, que son de baja entropía y necesitan un algoritmo lento y salado. Elegir uno para ambos sería un error en alguna dirección.

El selector de proyecto del dashboard solo aparece, y solo funciona, si la petición trae además una sesión de admin válida; se comprueba en el servidor en POST /dashboard/switch. Una sesión con solo la api_key de un proyecto nunca alcanza otro. requireAdminSession re-verifica contra la base de datos que la cuenta de la cookie sigue existiendo. Modelo completo en el README.

04

Lo que solo aparece contra infraestructura real

Lo obvio
Tests unitarios en verde, casos de uso con repositorios falsos: funciona.
Por qué falla
Los unitarios no ven el enrutador, ni el pool de conexiones, ni el user-agent con el que se prueba. Cuatro fallos pasaron por ahí sin ruido.
CORS que nunca corría
✓ unitarios: los tests de la API pasaban
✗ real: ServeMux respondía 405 al OPTIONS del preflight antes del middleware: ningún navegador de otro origen podía enviar un evento
Un /healthz que mentía
✓ unitarios: devolvía 200
✗ real: pgxpool conecta perezoso: el servidor se declaraba sano antes de que Postgres terminara las migraciones del primer arranque
El smoke test era un bot
✓ unitarios: el flujo completo pasaba
✗ real: curl sin User-Agent coincide con el heurístico anti-bot, así que sus eventos quedaban fuera de las stats. Comportamiento correcto, script incorrecto
El seed perdía eventos
✓ unitarios: el script terminaba sin error
✗ real: ráfagas desde una sola IP: el límite de 120 por minuto descartaba el resto. Era el limitador funcionando
Lo que se hizo
Tests de handlers HTTP reales, una suite de integración contra Postgres 16 que corre en CI como servicio, y un smoke test sobre docker compose con un volumen limpio. El healthz ahora hace pool.Ping, el compose espera a pg_isready, los preflight tienen rutas OPTIONS explícitas y el smoke manda un User-Agent de navegador.
17tests de integración contra un Postgres real, de 151 en total. CI: build, vet, gofmt, test, integración.
Ver detalle técnico

El bug de CORS es el más instructivo: el ServeMux de Go responde 405 a un OPTIONS contra un patrón registrado con otro método, antes de que ningún middleware corra. Los tests de la API pasaban porque nunca hacían un preflight. Lo cazó una prueba con un navegador real desde otro origen.

El de /healthz era una carrera: pgxpool conecta perezoso y el servidor se declaraba sano antes de que Postgres terminara las migraciones de initdb.d. Fallaba solo con un volumen fresco, de forma intermitente. Se arregló en dos capas, compose y endpoint.

05

La IP del visitante no llega a la base de datos

Lo obvio
Guardar la IP en cada evento y resolver país y ciudad llamando a una API externa.
Por qué falla
La IP es un dato personal, y una API externa envía la IP de cada visitante a un tercero en cada pageview. Justo lo que un analytics self-hosted promete no hacer.
IPv4 · llega
203.0.113.42
→
IPv4 · se guarda
203.0.113.0
IPv6 · llega
2001:db8:85a3:8d3:1319:8a2e:370:7348
→
IPv6 · se guarda
2001:db8:85a3::
Último octeto a cero en IPv4, últimos 80 bits en IPv6. Direcciones de ejemplo.
Lo que se hizo
El caso de uso resuelve la geolocalización sobre la IP original, en proceso, leyendo un .mmdb local de MaxMind, y después pone a cero el último octeto (IPv4) o los últimos 80 bits (IPv6) antes de construir el evento. Lo que se guarda es IPAnonymized; la IP completa no existe en ninguna tabla.
0llamadas de red por evento. Sin fichero GeoIP configurado, el resolver cae a no-op con un aviso, nunca a un fallo de arranque.
Ver detalle técnico

La base GeoIP no se distribuye con el binario: su licencia no lo permite. Se descarga con una cuenta gratuita y se apunta con GEOIP_DB_PATH. El límite por IP se aplica sobre la dirección original, antes de anonimizar; si no, todos los visitantes de una misma /24 compartirían cupo.

El script identifica al visitante con una cookie propia, _traccia_vid, un UUID aleatorio con dos años de vida. No es un analytics sin cookies: es un identificador que no cruza dominios ni se comparte con nadie.
"Un plugin nunca envía JavaScript al navegador."
Esa frase decide el resto: dashboard renderizado en el servidor, paneles declarativos, assets vendoreados y embebidos, un sandbox de tres objetos. Es más simple que un plugin-server y más accesible que WASM, y es lo que separa a Traccia de un producto que se extiende con un fork.
05 · Decisiones

Un solo filtro: ¿esto rompe la historia de un binario y un Postgres?

Si la respuesta era sí, se buscó otra forma. Si no la había, se escribió como límite en la documentación.

WHY
¿Por qué un producto completo y no una librería embebible?
Porque nadie self-hostea una librería sin interfaz. La gente levanta Umami o Plausible porque ve gráficos después de un docker compose up. Traccia empezó como la primera pieza de un backend de utilidades compartidas y pivotó a producto: API, dashboard, admin y plugins.
Profundizar

El script embebido y servido en /t.js es parte de la misma historia: un binario que trae su propio SDK, como GoatCounter. El mismo fichero es el paquete npm bajo sdk/js, sin build step en ninguno de los dos caminos. Cada tag publica binarios para linux y darwin, amd64 y arm64, y una imagen multi-arch en ghcr.io que corre como usuario sin privilegios.

WHY
goja en lugar de WASM o un plugin-server
goja es JavaScript en Go puro: no hay proceso aparte, no hay Node en la imagen, y corre en los tests sin Docker. Los extension points de la v1 son exactamente dos, onEvent y registerPanel, y el sandbox es cerrado por construcción: solo existe lo que el servidor le pone al runtime.
Profundizar

WASM daría aislamiento más fuerte y varios lenguajes, a cambio de que escribir un plugin exija una toolchain. Un plugin-server como el de PostHog escala mejor, a cambio de otro proceso. Para un producto cuyo argumento es "un binario", un intérprete embebido es el punto medio.

Trade-off asumido: un runtime por plugin con lock, porque goja no es thread-safe. Un plugin lento serializa las peticiones que le tocan. Referencia completa en docs/plugins.md.
WHY
Postgres como único estado y ningún job de fondo
Todo vive en una base de datos: eventos, visitantes, proyectos, cuentas y el kv de los plugins. Las migraciones son SQL plano que Postgres aplica en el primer arranque; después el proceso no ejecuta nada por su cuenta. La copia de seguridad es un pg_dump -Fc.
Profundizar

make backup escribe un dump comprimido y portable entre versiones de Postgres; make restore es destructivo y lo dice. No hay calendario automático: quien quiera uno lo pone en su cron. El límite por IP es en memoria y de un solo nodo; está declarado en el README como tal.

WHY
Hexagonal con ports intercambiables y licencia MIT
internal/domain importa solo time. Todo lo que necesitan los 17 casos de uso es una interfaz en internal/ports con un adapter por defecto. MIT prioriza adopción sobre proteger el proyecto de una reventa como SaaS, que es lo que AGPL intenta.
Profundizar

El modelo de eventos es track(nombre, metadata) más identify(rasgos), el mismo patrón que GA4, Segment y PostHog. Visitor.Name es un campo propio y no una propiedad más, para poder excluir el tráfico del dueño con exclude_named. Tabla de ports y adapters en el README.

06 · Qué haría distinto

Cinco decisiones que hoy tomaría al revés

Hice
Un /healthz que devolvía 200 sin tocar la base de datos
Hoy haría
pool.Ping desde el primer commit. pgxpool conecta perezoso: el servidor se declaraba sano antes de que Postgres terminara las migraciones.
Hice
Sesión de admin HMAC sin re-verificar contra la base de datos
Hoy haría
Comprobar en cada petición que la cuenta sigue existiendo. Un admin borrado conservaba acceso hasta siete días con una cookie sin estado.
Hice
Logins sin límite por IP en la primera versión
Hoy haría
Limitar /dashboard/login, /admin/login y /admin/setup desde el inicio. Es lo único entre una api_key y la fuerza bruta.
Hice
Aceptar chart: "line" | "bars" en el spec del plugin
Hoy haría
No aceptar en el spec lo que el dashboard no renderiza. Hoy todo panel es una tabla nombre/conteo.
Hice
Dejar fuera la recuperación de la contraseña de admin
Hoy haría
Sin SMTP el flujo es más grande de lo que parece; hoy recuperar acceso es borrar la fila de admin_users y rehacer el setup. Documentado en vez de construido a medias.
07 · Si te llevas tres cosas

Recap

1
Un binario y un Postgres: el script de tracking, la API, el dashboard y el admin salen del mismo proceso. Sin CDN, sin jobs de fondo, copia de seguridad con pg_dump.
2
Se extiende con un .js en una carpeta: engancha cada evento antes de guardarse y declara paneles. Corre dentro del binario con 100 ms de presupuesto, y un plugin roto nunca pierde un evento.
3
Hexagonal comprobable: el dominio importa solo time y el sistema de plugins entero es un decorador de un port. Los casos de uso no saben que existe.
Enlaces