A production pipeline that pulls app events from Kafka every day, cuts each session into goal-level journeys, clusters them with no labels, and tells the product team what users were trying to do and where they struggled.
Illustrative session with placeholder screen names. Times are seconds from session start.
The product team wanted to know which tasks customers come to the app for, which flows cause friction, and how iOS and Android differ. Nobody had labels, and the raw logs fight back in five ways.
Hand-labeling millions of sessions is impossible. Clusters have to emerge from the data; people only name them afterwards.
One session_id can last over 30 hours and mix a bill payment, a Wi-Fi reset and a support chat.
View and Action events store the screen name in different columns, so a naive join puts them in separate vocabularies.
iOS DeviceListVC and Android network_protect/device_management can be the same feature.
Contract codes, UUIDs and URL parameters turn every event into a near-unique token unless they are masked.
A quarter of data is 250 M+ rows. Every stage has to stream with bounded memory and rerun safely.
The app writes events to MongoDB, and they are published to a Kafka cluster. I built the sink connector that pulls those topics into SQL, and an Airflow DAG that runs every day to summarise and cluster the previous day's behaviour.
SQL exportYesterday's events, after the sink has committed the full day.
journey-partitionHashes (platform, session_id) so a session never splits across files.
journey-inferAndroid and iOS in parallel on the frozen champion model.
journey-nameJoins scores with the reviewed taxonomy mapping.
prepare_dashboard_dataDaily KPIs, cluster mix, friction and next-action tables.
PyArrow streaming and DuckDB. Training on hundreds of thousands of journeys per platform stays within a few GB of memory.
Data partitions and model runs carry separate IDs, so rerunning a day reuses what already exists.
Every scored journey stores model_version and source_partition.
{id}, {uuid}, {code}.One tap on the modem toggle, seen at five resolutions. Coarser levels let iOS and Android meet in the same space; tokens seen in fewer than 3 journeys back off one level.
| Level | Token | Granularity |
|---|---|---|
| Exact | action@ModemSettingsVC#toggle_power | Verbatim event |
| L3 intent | internet/modem/modem/toggle | family / module / object / operation |
| L2 coarse | internet/modem | family / module |
| L1 family | internet | business domain |
| Operation | configure:toggle | stage : operation |
Every scored journey gets a geometric check (distance to its cluster centre) and a generative check (how likely its screen transitions are under that cluster's Markov chain).
Rounded to whole percent. Unclassified includes noise (about 5 %) and clusters without enough naming evidence.
| Family | Android | iOS |
|---|
Each dot is one cluster. Longer journeys with more backtracking sit up and to the right. Dot area shows journey count. Values are rounded.
| HDBSCAN, per platform | Clusters | Silhouette | Davies–Bouldin | Calinski–Harabasz | Fit-time noise |
|---|---|---|---|---|---|
| Android | ~1,400 | 0.36 | 1.3 | ~8,700 | ~43 % |
| iOS | ~1,400 | 0.36 | 1.2 | ~7,600 | ~45 % |
HDBSCAN leaves irregular journeys out at fit time on purpose. At inference, nearest-centroid assignment and noise post-processing bring the unassigned share down to about 5 %.
Replays real clickstreams at 0.25× to 10× and classifies each journey on-device, with friction flags and next-action prediction.
A port with no external dependencies: float16 projection weights, centroid matrix and Markov graph shipped as compact binaries.
Python, Kotlin and Swift share tokenisation, masking and model files, so phone and warehouse agree on every journey.
Match clusters across retrains by medoid and n-gram overlap, so "cluster 7" keeps its meaning.
Promote branching-entropy cuts from opt-in to default once validated against the rules.
Add a learned sequence embedding as one more channel next to TF-IDF.