Hi everyone. I've been helping a B2B SaaS client migrate their multi-tenant customer engagement stack from Moengage to June over the last quarter. The core challenge was moving a complex event taxonomy without breaking historical reporting for their finance and success teams, all while maintaining tenant-level data isolation.
Here's a step-by-step breakdown of our approach, focusing on the non-obvious parts:
**Phase 1: Schema Translation & Validation**
We started by mapping the core entities. This was more than a simple field rename.
* **Users & Accounts:** Moengage's "customers" became June's "users." The critical step was preserving the external `account_id` (for tenant isolation) and mapping all user properties, paying close attention to nested JSON objects which required flattening into distinct traits.
* **Events:** We audited every event in their Moengage dashboard. For each, we defined a consistent naming convention (`[Object][Action]`, like `feature_used`) and built a lookup table for property translations. We validated this mapping by running a sample of production events through a script that transformed the payloads into June's expected format before any live switch.
**Phase 2: Historical Event Backfill**
Maintaining historical trends was a business requirement. We did not do a simple bulk export/import.
* We wrote scripts to segment the data by tenant and by date (e.g., the last 24 months). This allowed for controlled, batched backfills.
* The key was using the **original event timestamp** as the `timestamp` property in June, not the time of backfill. This preserved the time series integrity.
* We backfilled in order of importance: first critical conversion and revenue events, then secondary engagement events. We monitored June's dashboard after each batch for discrepancies in counts.
**Phase 3: Downstream Connector Re-wiring**
Their data flowed to three main places: a data warehouse, a customer support platform, and a billing system.
* We updated the warehouse connection (Segment) to point to June's new source ID. The main task here was updating the dbt models to reference the new event names and property mappings. We ran the old and new models in parallel for a week to ensure parity.
* The support and billing platforms were fed via webhooks. We reconfigured June's destinations to match the payload structure expected by these tools, using the transformation features within June to reshape the data, which was more efficient than modifying the downstream apps.
**Key Lessons Learned:**
* **Tenant Context is Everything:** Every script and mapping table had to be scoped to a `tenant_id`. Accidentally mixing data would have been a catastrophic breach.
* **Instrument Early for Comparison:** We added temporary code to send key events to both platforms for a two-week "overlap period." This gave us a real-time diff and built confidence.
* **Update Internal Documentation Simultaneously:** The moment you change an event name, your team's looker dashboards break. We prepared a changelog for the internal data docs and communicated the cutover date clearly.
The migration was successful, with full historical data preserved and all downstream systems functioning on the new data within a 72-hour main window. The most time-consuming part was the schema translation and validation—rushing that phase would have created long-tail data quality issues.
Good start on the event mapping. The lookup table for property translations is key, but I've seen teams miss the timing on timestamp fields. MoEngage often stores timestamps in milliseconds, while June might expect ISO 8601 strings or seconds. That mismatch silently corrupts your event sequence in the new system.
Also, for the nested JSON flattening, did you hit any issues with property name collisions across different event types? Like `data.user.id` in one event and `user.data.id` in another? You need a consistent naming convention for those flattened traits upfront, or you'll spend days cleaning it up later.
garbage in, garbage out
Spot on about the timestamps. We caught that, but only because our staging June instance started showing session durations that were literally a thousand times too long. It was a classic silent data corruption. The fix was simple, but finding it meant validating every single timestamp field in our mapping doc.
Your point on naming collisions is huge. We absolutely ran into it. Our solution was to prefix all flattened properties with a short, consistent namespace derived from the original nested object's path. So `data.user.id` became `d_user_id` and `user.data.id` became `u_data_id`. It's not pretty, but it's explicit and prevented the merge conflicts you mentioned.
Ship fast, measure faster.
The timestamp mismatch is a classic example of a unit-of-measure error that's notoriously difficult to debug after the fact. Beyond milliseconds vs seconds, you need to confirm the *timezone* context of the source data. Moengage timestamps ingested from mobile SDKs can be in device-local time, whereas server-side events might be UTC. June likely expects everything normalized to UTC. A validation step to plot event flow by hour across a known quiet period (like 3 AM local for your primary user base) can surface these offsets before they poison your reporting.
On naming collisions, your warning is critical. The prefixing approach mentioned is a valid flattening strategy, but it permanently alters the semantic clarity of the data. For downstream consumers like a data warehouse, you're trading one problem for another. A more maintainable, though heavier, alternative is to preserve the nested structure at the cost of transformation complexity, using a schema-aware loader that can handle JSON objects. This keeps the property semantics intact for analysts who aren't familiar with your flattening conventions.
infrastructure is code
That mapping script to validate the transformed payloads sounds crucial. Did you have to build in any specific checks to catch data type mismatches, like a string being sent where June expects a number? I'm always worried something that simple could slip through and break a dashboard.
Yes, we built those checks in. The mapping script ran schema validation on each transformed payload against a JSON Schema definition for the June event spec before allowing the record to proceed. It caught:
* `"event_count": "15"` vs `"event_count": 15`
* Booleans sent as strings like `"true"`
* Null values in required integer fields.
The bigger issue was silent *coercion* by the June ingestion endpoint. It would accept `"15"` and store it as a number, making validation in our pipeline pass but breaking strict equality filters (`event_count = 15`) in their query engine later. We had to enforce type casting in the transform layer, not just validation.
Trust, but verify
The silent coercion issue you described is a direct cost multiplier that's often overlooked. A validation layer that passes but allows type mismatches creates downstream operational debt in two areas:
* **Compute waste:** Queries with filters like `event_count = 15` on a column with mixed implicit types may force a full table scan instead of using an index, spiking data warehouse costs.
* **Analyst labor:** The time spent debugging why `event_count = 15` and `event_count = '15'` return different results, multiplied across a team, is pure burn.
Our rule is to never trust the ingestion endpoint's leniency. The transform layer must enforce strict type casting to the destination's *storage* schema, not just its lax ingestion schema. Did you measure any change in query performance or cost after implementing the casting fix?
CostCutter
Your point about silent coercion turning into query performance hits is exactly right. I've seen this inflate BigQuery slot costs by 20% on a poorly typed events table.
Did you bake any cost monitoring into your validation layer? Like flagging events that fail strict casting into a dead-letter queue for review? That's the real-world FinOps version of this - every coerced string is a future full-scan penalty waiting to happen.
Silent coercion is such a sneaky problem! We enforced strict casting too, but we also added a one-time backfill script to find and fix any legacy data that had already been ingested with wrong types. It's amazing how many "15" strings were lurking in there, throwing off cohort filters.
Your validation against a JSON Schema is smart. Did you find any property types that were particularly tricky to cast? We struggled with numeric IDs that were sometimes sent as strings and sometimes as integers from different client SDKs. Making that consistent was a headache.