Saltar a contenido

Análisis previo a la adopción (foto al 2026-09-03). Sus conclusiones duraderas están destiladas en ADR-0001, la guía de gestión y el runbook. Los objetos eval-* y el stack aquí descritos corresponden al banco de pruebas local.

Resultados de la evaluación funcional de Apache Unomi 3.0.0

Stack: apache/unomi:3.0.0 + Elasticsearch 9.1.3 (banco de pruebas unomi-eval/), host 6 vCPU / 22 GB, 2026-09-03. Suite: unomi-eval/tests/run-all.sh (fases 01..07 y 99-cleanup). Los logs, latencias y resultados de carga se generan en unomi-eval/tests/results/ (no versionado).

Resumen

Fase Script Resultado Asserts Duración Nota
1 01-schemas-eventos.sh PASS 28/28 13 s Esquemas aceptados; 5 variantes inválidas rechazadas
2 02-reglas-enriquecimiento.sh PASS 18/18 7 s R1 y R2 correctas; propiedad visible ~60-75 ms tras el evento
3 03-segmentos.sh PASS 19/19 15 s Entrada/salida en <100 ms; pastEventCondition funciona
4 04-identity-stitching.sh PASS 26/26 9 s Merge por email con alias; 2º dispositivo converge
5 05-personalizacion.sh PASS 12/12 5 s Requiere parche al tracker oficial (ver Fricción #1)
6 06-privacidad-persistencia.sh FAIL 33/34 81 s Único fallo: bug de 3.0.0 en /cxs/client/myprofile.* (Fricción #2). Restart OK
7 07-carga.sh PASS 6/6 58 s 5.000/5.000 aceptados, contadores consistentes
99 99-cleanup.sh PASS 7/7 84 s Limpieza completa verificada

Veredicto funcional: todo lo que un CDP necesita (esquemas, reglas, segmentos en tiempo real, identity stitching, privacidad, persistencia, carga moderada) funciona, con latencias de decenas de milisegundos. El costo está en la consistencia eventual (cachés de 1 s + refresh de ES) que hay que respetar en cualquier integración, y en dos bugs de 3.0.0: el tracker JS oficial no habla con el servidor sin parche y el endpoint de descarga de perfil da 500.

Latencias clave (medidas desde el host, results/latencias.txt)

Métrica Valor Cómo se midió
POST /cxs/context.json (sesión existente, requireSegments) p50 4,3 ms / p95 5,3 ms (n=50) curl secuencial, time_total
POST /cxs/context.json (sesión nueva por request) p50 15,2 ms / p95 18,3 ms (n=50) crea sesión + evento sessionCreated + reglas
context.json bajo carga (8 hilos inyectando) p50 13,6 ms / p95 70 ms / max 136 ms (n=345) hilo monitor cada 100 ms durante la fase 7
Evento → propiedad de perfil visible (regla R1) 72 ms (1ª), p50 58 / p95 68 ms (10 compras) desde el envío HTTP hasta que GET /cxs/profiles/{id} la muestra (poll 200 ms)
Evento → categoriaInteres (R2, 3er productView) 75 ms ídem
Entrada a segmento por propiedad (2ª compra) 57 ms poll de profile.segments
Salida de segmento (propiedad cambiada vía POST /cxs/profiles) 84 ms ídem
Entrada a eval-carrito-abandonado (addToCart) 61 ms pastEventCondition con contador en systemProperties.pastEvents
Salida de eval-carrito-abandonado (purchase) 56 ms ídem
Perfil preexistente incluido al crear el segmento 26 ms POST del segmento tarda 302 ms (recálculo síncrono); 3,5 s para el segmento con pastEventCondition (crea reglas ocultas y recalcula)
Segmento dependiente (profileSegmentCondition) 906 ms
Login con merge de 2 perfiles (HTTP) 1.105 ms (46 ms sin merge) el merge es síncrono en la request
Alias visible tras merge / eventos reasignados 29 ms / 899 ms reasignación de eventos/sesiones asíncrona
Esquema JSON activo tras POST /cxs/jsonSchema 20-280 ms polling de GET /cxs/jsonSchema
Evento visible en /cxs/events/search ~0,5-5 s flush del BulkProcessor (5 s)
docker compose restart → Unomi operativo 45 s reglas, segmentos, esquemas, scope y perfiles intactos

Carga (fase 7: 5.000 eventos, 200 perfiles, 8 hilos, 1 evento por request)

Métrica Valor
Eventos enviados / aceptados (HTTP 200) 5.000 / 5.000 (100 %)
Duración / throughput 41,8 s / 119,5 eventos/s (con reglas R1/R2 y 3 segmentos activos; cliente Python en el mismo host)
Latencia por evento (todos) p50 54,8 / p95 143,3 / p99 255,5 / max 734 ms
productView (sin regla que lo procese en la mayoría) p50 41,9 / p95 109 ms
addToCart / purchase (disparan reglas y segmentos) p50 77 / p95 163-166 ms
context.json durante la carga p50 13,6 / p95 70 ms
Consistencia 20 perfiles muestreados: totalPurchases = 3 = compras enviadas (sin lost updates); count(eval-compradores-recurrentes) = 206 ≥ 200
RAM Elasticsearch antes → después 1,58 GiB → 1,79 GiB
RAM Unomi antes → después 871 MiB → 923 MiB (CPU pico 29 % de un core antes de empezar, ~1 % en reposo)
Índices antes → después context-event: 39 docs / 464 kB → 5.039 docs / 3,1 MB · context-profile: 26 → 557 docs / 1,4 MB · context-session: 78 → 267 docs / 2,3 MB
Tamaño por evento en ES ~0,6 kB

Nota: 2.167 de los 3.000 productView devolvieron {"updated":0} (no cambian perfil ni sesión) y aun así se persistieron los 5.000 (contados en ES por prefijo de profileId).

Detalle por fase

Fase 1: esquemas de eventos (/cxs/jsonSchema)

Se registraron productView (productId, categoria, precio), addToCart (productId, cantidad) y purchase (orderId, total, items[]) como esquemas self.target=events con allOf del esquema base de evento, additionalProperties:false en properties y unevaluatedProperties:false en la raíz. Los tres eventos válidos pasan validateEvent sin errores, son procesados por /cxs/eventcollector y aparecen en ES. Rechazados (validateEvent con error y evento no persistido): propiedad no declarada (color), tipo incorrecto (cantidad:"dos"), required faltante (items), eventType sin esquema (refund) y campo extra en la raíz. Iteraciones para un esquema funcional: 1 (el formato del manual funcionó a la primera); 2 iteraciones para que el test fuera estable, por la activación diferida del esquema (ver Fricción #4). Re-POST del mismo esquema: idempotente (200).

Fase 2: reglas de enriquecimiento (/cxs/rules)

R1 (eval-r1-purchase): eventTypeCondition(purchase) → incrementPropertyAction(totalPurchases), setPropertyAction(lastPurchaseDate, setPropertyValueCurrentEventTimestamp), setPropertyAction(lastOrderId = eventProperty::properties(orderId)). Correcta tras 1, 2 y 12 compras. R2: pareja eval-r2a-count-electronica (incrementa vistasElectronica) + eval-r2b-interes-electronica (profilePropertyCondition vistasElectronica >= 2 → categoriaInteres = eventProperty::properties(categoria)). Verificado: nada tras 2 vistas, categoriaInteres=electronica exactamente en la 3ª, otras categorías no interfieren. Limitación de modelado en Fricción #15.

Fase 3: segmentos (/cxs/segments)

eval-compradores-recurrentes (totalPurchases >= 2) y eval-carrito-abandonado (pastEventCondition(addToCart, 7 días) AND NOT pastEventCondition(purchase, 7 días)). Unomi generó 2 reglas ocultas para los pastEventCondition y mantiene contadores en systemProperties.pastEvents del perfil. Entrada y salida verificadas en profile.segments, GET /cxs/profiles/{id}/segments y /cxs/segments/{id}/count; un addToCart posterior a la compra no re-ingresa (la ventana del purchase manda). Segmento dependiente eval-vip (profileSegmentCondition) también funciona.

Fase 4: identity stitching

Regla eval-login-merge: eventTypeCondition(login) → mergeProfilesOnPropertyAction(evalMergeIdentifier = eventProperty::target.properties(email)) + copyPropertiesAction. Login sin X-Unomi-Peer es ignorado. Dispositivo A (anónimo, 2 productView) hace login: conserva su id, recibe email/firstName y systemProperties.evalMergeIdentifier. Dispositivo B (anónimo, 1 addToCart, propiedades propias) hace login con el mismo email: B se fusiona en A, GET /cxs/profiles/B devuelve A (alias), B desaparece de ES, sus eventos y sesión pasan a A (asíncrono, <1 s), sus propiedades exclusivas llegan a A y en conflicto gana B (color: azul pisa rojo). Un tercer dispositivo con la cookie vieja de B se atribuye a A vía alias; un re-login no duplica aliases. Se conserva: propiedades (unión, gana el fusionado), systemProperties, segmentos (unión), consents, eventos y sesiones (reasignados). Se pierde: el id del perfil fusionado (queda como alias), sus scores, y el valor del master en propiedades en conflicto.

Fase 5: personalización con el tracker JS

05-personalizacion.html carga /tracker/unomi-web-tracker.min.js, inicializa el tracker con requireSegments:true y muestra banner verde si profileSegments incluye eval-compradores-recurrentes. Botones para cambiar de perfil (setean la cookie context-profile-id, renuevan la sesión y recargan). El script verifica por curl que context.json devuelve los segmentos correctos por profileId y por cookie, que CORS refleja el origen con credenciales, y mide latencias. Verificación manual en navegador: instrucciones impresas por el script (no había navegador en el host de evaluación; la página aplica el parche de Fricción #1, desactivable con ?patch=0).

Fase 6: privacidad y persistencia

DELETE /cxs/profiles/{id} → 204, GET posterior 204 vacío, documento ausente en ES; eventos y sesión quedan huérfanos. /cxs/privacy en 3.0.0: GET info (200), POST profiles/{id}/anonymize (204; purga address, email, facebookId, firstName, googleId, lastName, linkedInId, phoneNumber, twitterId; conserva el resto), GET/POST/DELETE profiles/{id}/anonymousBrowsing (200), GET/POST profiles/{id}/eventFilters (200; el tipo filtrado no se persiste), DELETE profiles/{id}/properties/{name} (200), DELETE profiles/{id}?withData=true (anonimiza eventos/sesión: profileId=null), ...&purgeAll=true (borra eventos y sesiones). Export: POST /cxs/profiles/export CSV (200) funciona; GET /cxs/client/myprofile.* → 500 (bug). Tras docker compose restart (45 s hasta operativo) persisten reglas, segmentos, esquemas custom, scope, perfiles y su membresía a segmentos; los eventos custom vuelven a validarse.

Fase 7: carga

Ver tabla "Carga". Perfil de latencia claramente bimodal: eventos que no disparan reglas ~40 ms, eventos que actualizan perfil + segmentos ~77 ms p50.

Fase 99: limpieza

Borra perfiles eval-* (con eventos y sesiones, en paralelo), segmentos (dependientes primero, dos pasadas), reglas (incluidas las auto-generadas), esquemas y scope; residuos de eventos/sesiones se eliminan por _delete_by_query directo en ES. Verifica que no quede nada.

Fricción: qué costó hacer funcionar y por qué

Bugs / comportamientos que contradicen el manual (3.0.0)

  1. El tracker JS oficial no funciona contra 3.0.0 sin parche. /tracker/unomi-web-tracker.min.js (apache-unomi-tracker 1.1.0, incluido en la imagen) envía context.json y eventcollector con Content-Type: text/plain;charset=UTF-8 "para evitar el preflight CORS". En 3.0.0 esos endpoints son JAX-RS con @Consumes(application/json): la respuesta es HTTP 415 envuelta en un 500 {"errorMessage":"internalServerError"}. El tag unomi-root-3.0.1 tiene las mismas anotaciones. Workaround aplicado en 05-personalizacion.html: envolver unomiWebTracker.ajax y forzar application/json (el preflight OPTIONS sí lo responde el filtro CORS de CXF). Fue el hallazgo que más tiempo costó: el curl de latencia inicialmente medía 500s en 2,7 ms.
  2. GET /cxs/client/myprofile.{json,csv,yaml,text} devuelve 500. Documentado en el manual ("Downloading profile data"). Log: NullPointerException ... ConfigSharingService.getProperty("allowedProfileDownloadFormats") is null. Alternativa que sí funciona: POST /cxs/profiles/export (CSV, autenticado) y POST /cxs/profiles/search.
  3. /cxs/context.json responde 500 durante ~10 s del arranque (No service defined for : setRemoteHostInfoAction) mientras /cxs/cluster ya devuelve 200. Un healthcheck sobre /cxs/cluster no garantiza que el endpoint público esté operativo.

Consistencia eventual que hay que conocer (fuente de todos los fallos intermitentes de la suite)

  1. Esquemas JSON: tras POST /cxs/jsonSchema el esquema tarda ~1-2 s en estar activo (persistencia en ES con refresh 1 s + recarga de caché cada 1 s). Eventos enviados antes se descartan silenciosamente. Costó 2 iteraciones: el formato del esquema funcionó a la primera; la espera de activación fue lo que falló.
  2. Scopes: mismo patrón (caché de 1 s). El validador rechaza Unknown scope value si el scope se creó <1-2 s antes.
  3. Perfiles recién creados no son "buscables" durante ~1 s (GET por id es realtime, pero /cxs/profiles/search y el recálculo de perfiles existentes al crear un segmento son queries). Un perfil creado justo antes de crear un segmento no entra en él hasta que otro evento lo re-evalúe.
  4. Borrado de segmentos con dependientes: al borrar un segmento referenciado por profileSegmentCondition, Unomi re-guarda los dependientes que tiene en caché y "resucita" uno borrado <1 s antes. Hay que borrar dependientes, esperar el refresh y luego el resto.
  5. Eventos: se escriben por BulkProcessor con flush de ~5 s; /cxs/events/search los ve con ese retraso. Las reglas y segmentos, en cambio, actúan en la misma request (latencias de decenas de ms, ver tabla).

Semántica de la API que sorprende

  1. {"updated": N} de /cxs/eventcollector no es "aceptado": es una máscara de cambios (sesión/perfil). Un evento válido sin regla que lo procese devuelve updated: 0 y aun así se persiste (en la carga, 2.167 de 3.000 productView dieron 0). Para saber si un evento fue rechazado por esquema hay que usar POST /cxs/jsonSchema/validateEvent (autenticado) o mirar el log (SchemaServiceImpl).
  2. La sesión manda sobre el profileId: si un sessionId ya está ligado a un perfil, eventcollector y context.json usan ese perfil e ignoran el profileId/cookie enviados. Cambiar de perfil exige renovar la sesión (la página de la fase 5 lo hace al pulsar cada botón).
  3. requireSegments solo viaja en el body de un POST: GET /cxs/context.json?sessionId=... nunca devuelve profileSegments.
  4. POST /cxs/profiles reemplaza el documento completo, no hace merge: para tocar una propiedad hay que hacer GET, modificar y POST (helper profile_set_props). Sí dispara profileUpdated y re-evalúa segmentos.
  5. DELETE /cxs/profiles/{id} no borra eventos ni sesiones (quedan huérfanos, con profileId del perfil borrado). El borrado "GDPR" es DELETE /cxs/privacy/profiles/{id}?withData=true (anonimiza: profileId=null en eventos y sesión) o &purgeAll=true (borra). Cada purga tarda ~3 s (dos delete_by_query con polling de tarea de 1 s): 220 perfiles = 11 min en serie; la limpieza se paralelizó con xargs -P 10 (66 s).
  6. No hay API para borrar eventos/sesiones sueltos; la limpieza recurre a _delete_by_query directo en ES.

Limitaciones de modelado encontradas

  1. Regla "3er productView de la misma categoría" no es expresable de forma dinámica. incrementPropertyAction incrementa una propiedad de nombre fijo (o suma un mapa que venga en target.properties, lo que obliga al cliente a mandar contadores). La solución fue una pareja de reglas estáticas por categoría (R2a incrementa vistasElectronica, R2b dispara con >= 2 porque las reglas que matchean se calculan antes de ejecutar acciones). Con N categorías son 2N reglas, o usar pastEventCondition dentro de la regla (query a ES con el retraso de flush de 5 s, no probado por no ser determinista).
  2. Merge de perfiles: el master es el primer perfil que devuelve la query por systemProperties.<clave> (no el más antiguo ni el del evento, salvo forceEventProfileAsMaster). En conflicto de propiedades gana el perfil fusionado (defaultMergeStrategy hace alwaysSet con el valor del perfil que se fusiona), los scores no se fusionan, los segments se unen, el perfil fusionado se borra y queda como alias. La reasignación de eventos/sesiones es asíncrona (~0,5 s medidos). El evento login exige X-Unomi-Peer y que la IP origen esté en UNOMI_THIRDPARTY_PROVIDER1_IPADDRESSES (en dev se abrió a 0.0.0.0/0).
  3. Estadísticas de reglas (/cxs/rules/{id}/statistics) mostraron executionCount=0 justo después de ejecutarse (se sincronizan cada 10 s; no confiar en ellas para asserts inmediatos).
  4. Sin multi-tenancy ni API keys en 3.0.0: los endpoints públicos (context.json, eventcollector) no tienen autenticación alguna; la protección es solo de red. Los eventos "seguros" (login, updateProperties) usan una clave compartida por header.