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)¶
- 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íacontext.jsonyeventcollectorconContent-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 tagunomi-root-3.0.1tiene las mismas anotaciones. Workaround aplicado en05-personalizacion.html: envolverunomiWebTracker.ajaxy forzarapplication/json(el preflightOPTIONSsí lo responde el filtro CORS de CXF). Fue el hallazgo que más tiempo costó: elcurlde latencia inicialmente medía 500s en 2,7 ms. 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) yPOST /cxs/profiles/search./cxs/context.jsonresponde 500 durante ~10 s del arranque (No service defined for : setRemoteHostInfoAction) mientras/cxs/clusterya devuelve 200. Un healthcheck sobre/cxs/clusterno garantiza que el endpoint público esté operativo.
Consistencia eventual que hay que conocer (fuente de todos los fallos intermitentes de la suite)¶
- Esquemas JSON: tras
POST /cxs/jsonSchemael 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ó. - Scopes: mismo patrón (caché de 1 s). El validador rechaza
Unknown scope valuesi el scope se creó <1-2 s antes. - Perfiles recién creados no son "buscables" durante ~1 s (GET por id es realtime, pero
/cxs/profiles/searchy 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. - 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. - Eventos: se escriben por BulkProcessor con flush de ~5 s;
/cxs/events/searchlos 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¶
{"updated": N}de/cxs/eventcollectorno es "aceptado": es una máscara de cambios (sesión/perfil). Un evento válido sin regla que lo procese devuelveupdated: 0y aun así se persiste (en la carga, 2.167 de 3.000productViewdieron 0). Para saber si un evento fue rechazado por esquema hay que usarPOST /cxs/jsonSchema/validateEvent(autenticado) o mirar el log (SchemaServiceImpl).- La sesión manda sobre el
profileId: si unsessionIdya está ligado a un perfil,eventcollectorycontext.jsonusan ese perfil e ignoran elprofileId/cookie enviados. Cambiar de perfil exige renovar la sesión (la página de la fase 5 lo hace al pulsar cada botón). requireSegmentssolo viaja en el body de un POST:GET /cxs/context.json?sessionId=...nunca devuelveprofileSegments.POST /cxs/profilesreemplaza el documento completo, no hace merge: para tocar una propiedad hay que hacer GET, modificar y POST (helperprofile_set_props). Sí disparaprofileUpdatedy re-evalúa segmentos.DELETE /cxs/profiles/{id}no borra eventos ni sesiones (quedan huérfanos, conprofileIddel perfil borrado). El borrado "GDPR" esDELETE /cxs/privacy/profiles/{id}?withData=true(anonimiza:profileId=nullen eventos y sesión) o&purgeAll=true(borra). Cada purga tarda ~3 s (dosdelete_by_querycon polling de tarea de 1 s): 220 perfiles = 11 min en serie; la limpieza se paralelizó conxargs -P 10(66 s).- No hay API para borrar eventos/sesiones sueltos; la limpieza recurre a
_delete_by_querydirecto en ES.
Limitaciones de modelado encontradas¶
- Regla "3er productView de la misma categoría" no es expresable de forma dinámica.
incrementPropertyActionincrementa una propiedad de nombre fijo (o suma un mapa que venga entarget.properties, lo que obliga al cliente a mandar contadores). La solución fue una pareja de reglas estáticas por categoría (R2a incrementavistasElectronica, R2b dispara con>= 2porque las reglas que matchean se calculan antes de ejecutar acciones). Con N categorías son 2N reglas, o usarpastEventConditiondentro de la regla (query a ES con el retraso de flush de 5 s, no probado por no ser determinista). - 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, salvoforceEventProfileAsMaster). En conflicto de propiedades gana el perfil fusionado (defaultMergeStrategyhacealwaysSetcon el valor del perfil que se fusiona), losscoresno se fusionan, lossegmentsse unen, el perfil fusionado se borra y queda como alias. La reasignación de eventos/sesiones es asíncrona (~0,5 s medidos). El eventologinexigeX-Unomi-Peery que la IP origen esté enUNOMI_THIRDPARTY_PROVIDER1_IPADDRESSES(en dev se abrió a0.0.0.0/0). - Estadísticas de reglas (
/cxs/rules/{id}/statistics) mostraronexecutionCount=0justo después de ejecutarse (se sincronizan cada 10 s; no confiar en ellas para asserts inmediatos). - 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.