Repository: customerio/customerio-android
Pull request #508: fix: support null handling in nested structures
Author: login: Shahroz16
Created: 2025-04-08T09:38:14Z | Merged: 2025-04-10T14:37:41Z

closes: [MBL-955](https://linear.app/customerio/issue/MBL-955/[bug]-passing-nested-null-attribute-crashes-android-in-react-native)

### Changes

- Added `sanitizeNullsForJson()` extension to recursively replace all `null` values with `JsonNull` in `CustomAttributes` for `Map` and `List` before forwarding to `analytics`
- Added unit tests to validate the behavior of new extension
- Added tests on `CustomerIO.instance` to validate end-to-end behavior
- Updated Datapipeline method to cater `Map<String,Any?>` so they can cater cases of both, `CustomeAttribute` and `mapof type <string, any?>` 
 
#### Sample Code

Crash on `main` before this fix:

```kotlin
CustomerIO.instance().track(
    "Add to Cart",
    mapOf(
        "item" to mapOf(
            "id" to "100",
            "name" to "Mobile",
            "discount" to null,
        ),
    )
)
```


Diff:
diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/extensions/JsonExtensions.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/extensions/JsonExtensions.kt
index b54ee88c9..2f0c85725 100644
--- a/datapipelines/src/main/kotlin/io/customer/datapipelines/extensions/JsonExtensions.kt
+++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/extensions/JsonExtensions.kt
@@ -43,3 +43,16 @@ private fun Any?.toSerializableJson(): JsonElement {
         else -> this.toJsonElement()
     }
 }
+
+/**
+ * Recursively replaces all `null` values in nested structure with [JsonNull],
+ * preserving structure and type. Useful for safely converting to `JsonElement`
+ * using `Any.toJsonElement()` extension.
+ */
+@Suppress("UNCHECKED_CAST")
+internal fun <T> T?.sanitizeNullsForJson(): T = when (this) {
+    null -> JsonNull as T
+    is Map<*, *> -> this.mapValues { (_, v) -> v.sanitizeNullsForJson() } as T
+    is List<*> -> this.map { it.sanitizeNullsForJson() } as T
+    else -> this
+}
diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt
index dfb3917fd..3024e8214 100644
--- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt
+++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt
@@ -13,6 +13,7 @@ import io.customer.base.internal.InternalCustomerIOApi
 import io.customer.datapipelines.config.DataPipelinesModuleConfig
 import io.customer.datapipelines.di.analyticsFactory
 import io.customer.datapipelines.extensions.asMap
+import io.customer.datapipelines.extensions.sanitizeNullsForJson
 import io.customer.datapipelines.extensions.type
 import io.customer.datapipelines.extensions.updateAnalyticsConfig
 import io.customer.datapipelines.migration.TrackingMigrationProcessor
@@ -186,8 +187,8 @@ class CustomerIO private constructor(
             if (identifier != null) {
                 identify(userId = identifier, traits = value)
             } else {
-                logger.debug("No user profile found, updating traits for anonymous user ${analytics.anonymousId()}")
-                analytics.identify(traits = value)
+                logger.debug("No user profile found, updating sanitized traits for anonymous user ${analytics.anonymousId()}")
+                analytics.identify(traits = value.sanitizeNullsForJson())
             }
         }
 
diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/DataPipelineInstance.kt b/datapipelines/src/main/kotlin/io/customer/sdk/DataPipelineInstance.kt
index 7e20ae92e..907b354cf 100644
--- a/datapipelines/src/main/kotlin/io/customer/sdk/DataPipelineInstance.kt
+++ b/datapipelines/src/main/kotlin/io/customer/sdk/DataPipelineInstance.kt
@@ -2,6 +2,7 @@ package io.customer.sdk
 
 import com.segment.analytics.kotlin.core.emptyJsonObject
 import com.segment.analytics.kotlin.core.utilities.JsonAnySerializer
+import io.customer.datapipelines.extensions.sanitizeNullsForJson
 import io.customer.sdk.data.model.CustomAttributes
 import io.customer.sdk.events.TrackMetric
 import kotlinx.serialization.SerializationStrategy
@@ -64,9 +65,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
      * [Learn more](https://customer.io/docs/api/#operation/identify)
      * @param traits Map of <String, Any> to be added
      */
-    fun identify(userId: String, traits: CustomAttributes) {
+    fun identify(userId: String, traits: Map<String, Any?>) {
         // Method needed for Java interop as inline doesn't work with Java
-        identify(userId = userId, traits = traits, serializationStrategy = JsonAnySerializer.serializersModule.serializer())
+        identify(userId = userId, traits = traits.sanitizeNullsForJson(), serializationStrategy = JsonAnySerializer.serializersModule.serializer())
     }
 
     /**
@@ -128,9 +129,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
      * @param properties Map of <String, Any> to be added
      * @see [Learn more](https://customer.io/docs/cdp/sources/source-spec/track-spec/)
      */
-    fun track(name: String, properties: CustomAttributes) {
+    fun track(name: String, properties: Map<String, Any?>) {
         // Method needed for Java interop as inline doesn't work with Java
-        track(name = name, properties = properties, serializationStrategy = JsonAnySerializer.serializersModule.serializer())
+        track(name = name, properties = properties.sanitizeNullsForJson(), serializationStrategy = JsonAnySerializer.serializersModule.serializer())
     }
 
     /**
@@ -201,9 +202,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
      * @param properties Additional details about the screen in Map <String, Any> format.
      * @see [Learn more](https://customer.io/docs/cdp/sources/source-spec/screen-spec/)
      */
-    fun screen(title: String, properties: CustomAttributes) {
+    fun screen(title: String, properties: Map<String, Any?>) {
         // Method needed for Java interop as inline doesn't work with Java
-        screen(title = title, properties = properties, serializationStrategy = JsonAnySerializer.serializersModule.serializer())
+        screen(title = title, properties = properties.sanitizeNullsForJson(), serializationStrategy = JsonAnySerializer.serializersModule.serializer())
     }
 
     /**
diff --git a/datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesInteractionTests.kt b/datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesInteractionTests.kt
index 83399720c..f38928ab7 100644
--- a/datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesInteractionTests.kt
+++ b/datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesInteractionTests.kt
@@ -658,4 +658,204 @@ class DataPipelinesInteractionTests : JUnitTest() {
     }
 
     //endregion
+
+    //region Nested Null Handling Tests
+
+    @Test
+    fun track_givenNestedNullInCustomAttributes_expectSuccessfulEventProcessing() {
+        val givenEvent = String.random
+        val givenProperties = mapOf(
+            "level1" to mapOf(
+                "key1" to "value1",
+                "key2" to null,
+                "nested" to mapOf(
+                    "deepKey" to "deepValue",
+                    "nullValue" to null
+                )
+            ),
+            "nullObject" to null,
+            "regularValue" to "test"
+        )
+
+        sdkInstance.track(givenEvent, givenProperties)
+
+        outputReaderPlugin.trackEvents.size shouldBeEqualTo 1
+        val trackEvent = outputReaderPlugin.trackEvents.lastOrNull()
+        trackEvent.shouldNotBeNull()
+
+        trackEvent.event shouldBeEqualTo givenEvent
+
+        // Verify event was processed successfully and properties match
+        // The outputReaderPlugin should have the properties with JsonNull replacing nulls
+        val level1Map = trackEvent.properties["level1"]
+        level1Map.shouldNotBeNull()
+
+        // Ensure we can access the properties without errors, which would happen if nulls weren't properly handled
+        val nestedMap = (level1Map as Map<*, *>)["nested"]
+        nestedMap.shouldNotBeNull()
+    }
+
+    @Test
+    fun track_givenNullInDeepNestedStructure_expectSuccessfulEventProcessing() {
+        val givenEvent = String.random
+        val userData: CustomAttributes = mapOf(
+            "name" to "John Doe",
+            "contact" to mapOf(
+                "email" to "john@example.com",
+                "phone" to null, // Null phone
+                "address" to mapOf(
+                    "street" to "123 Main St",
+                    "city" to null, // Null city
+                    "zipCode" to "12345"
+                )
+            ),
+            "preferences" to mapOf(
+                "notifications" to mapOf(
+                    "email" to true,
+                    "push" to null, // Null push preference
+                    "frequency" to mapOf(
+                        "daily" to true,
+                        "weekly" to null // Nested null
+                    )
+                ),
+                "theme" to null // Null theme
+            )
+        )
+
+        sdkInstance.track(givenEvent, userData)
+
+        outputReaderPlugin.trackEvents.size shouldBeEqualTo 1
+        val trackEvent = outputReaderPlugin.trackEvents.lastOrNull()
+        trackEvent.shouldNotBeNull()
+
+        trackEvent.event shouldBeEqualTo givenEvent
+
+        // Verify the nested structure with nulls was processed correctly
+        val contact = trackEvent.properties["contact"] as? Map<*, *>
+        contact.shouldNotBeNull()
+
+        val address = contact["address"] as? Map<*, *>
+        address.shouldNotBeNull()
+
+        val preferences = trackEvent.properties["preferences"] as? Map<*, *>
+        preferences.shouldNotBeNull()
+
+        val notifications = preferences["notifications"] as? Map<*, *>
+        notifications.shouldNotBeNull()
+
+        val frequency = notifications["frequency"] as? Map<*, *>
+        frequency.shouldNotBeNull()
+    }
+
+    @Test
+    fun track_givenArrayWithNestedNulls_expectSuccessfulEventProcessing() {
+        val givenEvent = String.random
+        val givenProperties: CustomAttributes = mapOf(
+            "items" to listOf(
+                mapOf("id" to 1, "value" to "first", "optional" to null),
+                mapOf("id" to 2, "value" to null, "details" to mapOf("key" to null)),
+                null
+            )
+        )
+
+        sdkInstance.track(givenEvent, givenProperties)
+
+        outputReaderPlugin.trackEvents.size shouldBeEqualTo 1
+        val trackEvent = outputReaderPlugin.trackEvents.lastOrNull()
+        trackEvent.shouldNotBeNull()
+
+        trackEvent.event shouldBeEqualTo givenEvent
+
+        // Verify the event was processed successfully
+        val items = trackEvent.properties["items"]
+        items.shouldNotBeNull()
+        (items as List<*>).size shouldBeEqualTo 3
+    }
+
+    @Test
+    fun identify_givenNestedNullInAttributes_expectSuccessfulProfileCreation() {
+        val givenIdentifier = String.random
+        val givenTraits: CustomAttributes = mapOf(
+            "profile" to mapOf(
+                "firstName" to "Jane",
+                "lastName" to "Doe",
+                "settings" to mapOf(
+                    "language" to "en",
+                    "timezone" to null,
+                    "preferences" to mapOf(
+                        "notifications" to true,
+                        "marketing" to null
+                    )
+                ),
+                "company" to null
+            )
+        )
+
+        sdkInstance.identify(givenIdentifier, givenTraits)
+
+        analytics.userId() shouldBeEqualTo givenIdentifier
+
+        outputReaderPlugin.identifyEvents.size shouldBeEqualTo 1
+        val identifyEvent = outputReaderPlugin.identifyEvents.lastOrNull()
+        identifyEvent.shouldNotBeNull()
+
+        identifyEvent.userId shouldBeEqualTo givenIdentifier
+
+        // If this passes, it means the SDK correctly handled the nested nulls
+        val profile = identifyEvent.traits["profile"] as? Map<*, *>
+        profile.shouldNotBeNull()
+
+        val settings = profile["settings"] as? Map<*, *>
+        settings.shouldNotBeNull()
+
+        val preferences = settings["preferences"] as? Map<*, *>
+        preferences.shouldNotBeNull()
+    }
+
+    @Test
+    fun screen_givenNestedNullInProperties_expectSuccessfulScreenView() {
+        val givenTitle = String.random
+        val givenProperties: CustomAttributes = mapOf(
+            "view" to mapOf(
+                "id" to "home_screen",
+                "elements" to listOf(
+                    mapOf("type" to "button", "visible" to true),
+                    mapOf("type" to "input", "value" to null),
+                    null
+                ),
+                "metadata" to mapOf(
+                    "version" to "1.0",
+                    "debug" to null,
+                    "nested" to mapOf(
+                        "key" to "value",
+                        "optional" to null
+                    )
+                )
+            )
+        )
+
+        sdkInstance.screen(givenTitle, givenProperties)
+
+        outputReaderPlugin.screenEvents.size shouldBeEqualTo 1
+        val screenEvent = outputReaderPlugin.screenEvents.lastOrNull()
+        screenEvent.shouldNotBeNull()
+
+        screenEvent.name shouldBeEqualTo givenTitle
+
+        // Verify the event properties were correctly processed with null handling
+        val view = screenEvent.properties["view"] as? Map<*, *>
+        view.shouldNotBeNull()
+
+        val elements = view["elements"] as? List<*>
+        elements.shouldNotBeNull()
+        elements.size shouldBeEqualTo 3
+
+        val metadata = view["metadata"] as? Map<*, *>
+        metadata.shouldNotBeNull()
+
+        val nested = metadata["nested"] as? Map<*, *>
+        nested.shouldNotBeNull()
+    }
+
+    //endregion
 }
diff --git a/datapipelines/src/test/java/io/customer/datapipelines/plugins/CustomerIODestinationTest.kt b/datapipelines/src/test/java/io/customer/datapipelines/plugins/CustomerIODestinationTest.kt
index f420e6102..3d4842ede 100644
--- a/datapipelines/src/test/java/io/customer/datapipelines/plugins/CustomerIODestinationTest.kt
+++ b/datapipelines/src/test/java/io/customer/datapipelines/plugins/CustomerIODestinationTest.kt
@@ -6,11 +6,14 @@ import io.customer.datapipelines.testutils.core.JUnitTest
 import io.customer.datapipelines.testutils.core.testConfiguration
 import io.customer.datapipelines.testutils.utils.OutputReaderPlugin
 import io.customer.datapipelines.testutils.utils.trackEvents
+import io.customer.sdk.data.model.CustomAttributes
 import io.mockk.every
 import io.mockk.mockkConstructor
 import okio.IOException
 import org.amshove.kluent.shouldBe
+import org.amshove.kluent.shouldBeEqualTo
 import org.amshove.kluent.shouldNotBe
+import org.amshove.kluent.shouldNotBeNull
 import org.junit.jupiter.api.Test
 
 /**
@@ -62,4 +65,69 @@ class CustomerIODestinationTest : JUnitTest() {
         integrations shouldNotBe null
         (integrations?.contains(CUSTOMER_IO_DATA_PIPELINES) ?: false) shouldBe true
     }
+
+    @Test
+    fun givenComplexEventWithNestedNulls_expectSuccessfulEventProcessingThroughDestination() {
+        // Create an event with complex nested structure containing nulls
+        val eventName = "complex_event"
+        val properties: CustomAttributes = mapOf(
+            "customer" to mapOf(
+                "id" to 123,
+                "name" to "Test Customer",
+                "account" to mapOf(
+                    "type" to "premium",
+                    "paymentMethod" to null, // Null at deeper level
+                    "details" to mapOf(
+                        "active" to true,
+                        "lastPaymentDate" to null // Another null at even deeper level
+                    )
+                ),
+                "preferences" to null // Null at second level
+            ),
+            "products" to listOf(
+                mapOf("id" to 1, "name" to "Product A", "variants" to null), // Null in list item
+                mapOf(
+                    "id" to 2,
+                    "name" to "Product B",
+                    "variants" to listOf(
+                        mapOf("color" to "red", "size" to null), // Null in nested list item
+                        null // Null list item in nested list
+                    )
+                ),
+                null // Null list item
+            )
+        )
+
+        // Track event with complex properties containing nulls
+        sdkInstance.track(eventName, properties)
+
+        // Verify the event was processed successfully
+        outputReaderPlugin.trackEvents.size shouldBeEqualTo 1
+        val event = outputReaderPlugin.trackEvents.first()
+        event.shouldNotBeNull()
+        event.event shouldBeEqualTo eventName
+
+        // Verify the nested structures were preserved
+        val customer = event.properties["customer"] as? Map<*, *>
+        customer.shouldNotBeNull()
+
+        val account = customer["account"] as? Map<*, *>
+        account.shouldNotBeNull()
+
+        val details = account["details"] as? Map<*, *>
+        details.shouldNotBeNull()
+
+        // Verify the list was properly processed
+        val products = event.properties["products"] as? List<*>
+        products.shouldNotBeNull()
+        products.size shouldBeEqualTo 3
+
+        // Verify an item with a nested list was processed correctly
+        val productB = products[1] as? Map<*, *>
+        productB.shouldNotBeNull()
+
+        val variants = productB["variants"] as? List<*>
+        variants.shouldNotBeNull()
+        variants.size shouldBeEqualTo 2
+    }
 }
diff --git a/datapipelines/src/test/java/io/customer/datapipelines/util/SanitizeNullsForJsonTest.kt b/datapipelines/src/test/java/io/customer/datapipelines/util/SanitizeNullsForJsonTest.kt
new file mode 100644
index 000000000..20127b4ce
--- /dev/null
+++ b/datapipelines/src/test/java/io/customer/datapipelines/util/SanitizeNullsForJsonTest.kt
@@ -0,0 +1,68 @@
+package io.customer.datapipelines.util
+
+import io.customer.datapipelines.extensions.sanitizeNullsForJson
+import io.customer.datapipelines.testutils.core.JUnitTest
+import kotlinx.serialization.json.JsonNull
+import org.amshove.kluent.shouldBeEqualTo
+import org.junit.jupiter.api.Test
+
+class SanitizeNullsForJsonTest : JUnitTest() {
+    @Test
+    fun sanitize_givenNull_expectJsonNull() {
+        val input: String? = null
+        val expected = JsonNull
+        input.sanitizeNullsForJson() shouldBeEqualTo expected
+    }
+
+    @Test
+    fun sanitize_givenFlatMapWithNull_expectJsonNullReplacement() {
+        val input = mapOf("a" to 1, "b" to null)
+        val expected = mapOf("a" to 1, "b" to JsonNull)
+        input.sanitizeNullsForJson() shouldBeEqualTo expected
+    }
+
+    @Test
+    fun sanitize_givenNestedMapWithNull_expectJsonNullReplacement() {
+        val input = mapOf("meta" to mapOf("os" to "android", "root" to false, "vendor" to null, "version" to 13))
+        val expected = mapOf("meta" to mapOf("os" to "android", "root" to false, "vendor" to JsonNull, "version" to 13))
+        input.sanitizeNullsForJson() shouldBeEqualTo expected
+    }
+
+    @Test
+    fun sanitize_givenListWithNull_expectJsonNullReplacement() {
+        val input = listOf("a", null, "b")
+        val expected = listOf("a", JsonNull, "b")
+        input.sanitizeNullsForJson() shouldBeEqualTo expected
+    }
+
+    @Test
+    fun sanitize_givenNestedStructureWithNull_expectJsonNullReplacement() {
+        val input = mapOf(
+            "maps" to listOf(
+                mapOf("key" to null),
+                mapOf("key" to "value")
+            ),
+            "colors" to listOf("red", null, "blue")
+        )
+        val expected = mapOf(
+            "maps" to listOf(
+                mapOf("key" to JsonNull),
+                mapOf("key" to "value")
+            ),
+            "colors" to listOf("red", JsonNull, "blue")
+        )
+        input.sanitizeNullsForJson() shouldBeEqualTo expected
+    }
+
+    @Test
+    fun sanitize_givenMapWithoutNull_expectReturnSameStructure() {
+        val input = mapOf("x" to 42, "y" to "hello")
+        input.sanitizeNullsForJson() shouldBeEqualTo input
+    }
+
+    @Test
+    fun sanitize_givenListWithoutNull_expectReturnSameStructure() {
+        val input = listOf("apple", "banana", "cherry")
+        input.sanitizeNullsForJson() shouldBeEqualTo input
+    }
+}


Reviews:
user: login: mrehan27
body: Looks good 👍🏻

We can roll it out after testing the changes on wrapper SDKs.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T13:06:12Z
user: login: mahmoud-elmorabea
body: Approach looks solid! 💯 

Just wanted to double check on two main concerns:
- I think we might have missed at least one place using `CustomAttributes`, maybe it's worth doing a search for it to make sure we covered all. I only found one though
- I cross tested with iOS quickly and it seems like iOS omits the value all together rather than sending it null, but it's not consistent, have we cross checked behavior with iOS?
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T13:11:21Z
user: login: Shahroz16
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T20:39:19Z
user: login: mahmoud-elmorabea
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T07:00:34Z
user: login: mahmoud-elmorabea
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T07:02:13Z
user: login: Shahroz16
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T09:28:54Z
user: login: Shahroz16
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T14:04:41Z

Review comments:
diff_hunk: @@ -658,4 +658,204 @@ class DataPipelinesInteractionTests : JUnitTest() {
     }
 
     //endregion
+
+    //region Nested Null Handling Tests
+
+    @Test
path: datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesInteractionTests.kt
user: login: mahmoud-elmorabea
body: Thanks for adding the tests! 

~Can we add a test where traits has no nulls? To prove that the base case works without any recursion?~

Scratch that, it's covered in `SanitizeNullsForJsonTest`
created_at: 2025-04-09T12:51:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,9 +65,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
      * [Learn more](https://customer.io/docs/api/#operation/identify)
      * @param traits Map of <String, Any> to be added
      */
-    fun identify(userId: String, traits: CustomAttributes) {
path: datapipelines/src/main/kotlin/io/customer/sdk/DataPipelineInstance.kt
user: login: mahmoud-elmorabea
body: It looks like in the `CustomerIO` implementation the setter of profile attributes is calling `analytics.identify(traits)` directly in case of anonymous users, this will bypass the sanitization happening at the abstract `DataPipelineInstance`, right?
created_at: 2025-04-09T13:05:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,9 +65,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
      * [Learn more](https://customer.io/docs/api/#operation/identify)
      * @param traits Map of <String, Any> to be added
      */
-    fun identify(userId: String, traits: CustomAttributes) {
+    fun identify(userId: String, traits: Map<String, Any?>) {
         // Method needed for Java interop as inline doesn't work with Java
-        identify(userId = userId, traits = traits, serializationStrategy = JsonAnySerializer.serializersModule.serializer())
+        identify(userId = userId, traits = traits.sanitizeNullsForJson(), serializationStrategy = JsonAnySerializer.serializersModule.serializer())
path: datapipelines/src/main/kotlin/io/customer/sdk/DataPipelineInstance.kt
user: login: mahmoud-elmorabea
body: Do we not think it's more appriopriate to do this sanitization at the implementation level `CustomerIO` class instead of the abstraction? 

If `CustomerIO` is called from any other place in the future directly, it will have the same problem
created_at: 2025-04-09T13:10:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,9 +65,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
      * [Learn more](https://customer.io/docs/api/#operation/identify)
      * @param traits Map of <String, Any> to be added
      */
-    fun identify(userId: String, traits: CustomAttributes) {
path: datapipelines/src/main/kotlin/io/customer/sdk/DataPipelineInstance.kt
user: login: Shahroz16
body: thats a good call out
created_at: 2025-04-09T19:51:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,9 +65,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
      * [Learn more](https://customer.io/docs/api/#operation/identify)
      * @param traits Map of <String, Any> to be added
      */
-    fun identify(userId: String, traits: CustomAttributes) {
+    fun identify(userId: String, traits: Map<String, Any?>) {
         // Method needed for Java interop as inline doesn't work with Java
-        identify(userId = userId, traits = traits, serializationStrategy = JsonAnySerializer.serializersModule.serializer())
+        identify(userId = userId, traits = traits.sanitizeNullsForJson(), serializationStrategy = JsonAnySerializer.serializersModule.serializer())
path: datapipelines/src/main/kotlin/io/customer/sdk/DataPipelineInstance.kt
user: login: Shahroz16
body: I initially went with that solution, but it didn't work out. why?
- If we try to sanitize data types other than a map, we are going to get classcast exception 
e.g
```
fun <Traits> identify(
        userId: String,
        traits: Traits,
        serializationStrategy: SerializationStrategy<Traits>
    ) {
        synchronized {
            identifyImpl(userId, traits.sanitizeNullsForJson(), serializationStrategy)
        }
    }
```

you are going to face this crash 

```
class java.util.LinkedHashMap cannot be cast to class kotlinx.serialization.json.JsonObject (java.util.LinkedHashMap is in module java.base of loader 'bootstrap'; kotlinx.serialization.json.JsonObject is in unnamed module of loader 'app')
```

Because:
	•	After calling `sanitizeNullsForJson()`, we end up with a plain Kotlin Map (LinkedHashMap) rather than a JsonObject.
	•	Our serializer (the SerializationStrategy) expects a JsonObject (or JsonElement), so it tries to cast the LinkedHashMap to JsonObject, causing the ClassCastException.
	•	In the case where we directly pass a concrete Map<String, Any?> to identify(...), the code uses a map-based serializer, so there is no cast to JsonObject and thus no crash.
	•	In contrast, when we call the generic identify(...) method, the serializer is inferred or supplied such that it’s expecting a JsonObject instead of a map, leading to the casting error.
created_at: 2025-04-09T20:05:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,9 +65,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
      * [Learn more](https://customer.io/docs/api/#operation/identify)
      * @param traits Map of <String, Any> to be added
      */
-    fun identify(userId: String, traits: CustomAttributes) {
+    fun identify(userId: String, traits: Map<String, Any?>) {
         // Method needed for Java interop as inline doesn't work with Java
-        identify(userId = userId, traits = traits, serializationStrategy = JsonAnySerializer.serializersModule.serializer())
+        identify(userId = userId, traits = traits.sanitizeNullsForJson(), serializationStrategy = JsonAnySerializer.serializersModule.serializer())
path: datapipelines/src/main/kotlin/io/customer/sdk/DataPipelineInstance.kt
user: login: mahmoud-elmorabea
body: Thanks for the clarification! 
created_at: 2025-04-10T07:00:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,9 +65,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
      * [Learn more](https://customer.io/docs/api/#operation/identify)
      * @param traits Map of <String, Any> to be added
      */
-    fun identify(userId: String, traits: CustomAttributes) {
path: datapipelines/src/main/kotlin/io/customer/sdk/DataPipelineInstance.kt
user: login: mahmoud-elmorabea
body: @Shahroz16 Did we fix this one yet? I still see that the direct call to `analytics.identify(traits)` is not sanitized 
created_at: 2025-04-10T07:02:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,9 +65,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
      * [Learn more](https://customer.io/docs/api/#operation/identify)
      * @param traits Map of <String, Any> to be added
      */
-    fun identify(userId: String, traits: CustomAttributes) {
path: datapipelines/src/main/kotlin/io/customer/sdk/DataPipelineInstance.kt
user: login: Shahroz16
body: I did not sure why its not showing up 😟 
<img width="1897" alt="Screenshot 2025-04-10 at 2 28 30 PM" src="https://github.com/user-attachments/assets/6901f78c-e222-4a1b-95c7-ee5c6608a16e" />

created_at: 2025-04-10T09:28:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,9 +65,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
      * [Learn more](https://customer.io/docs/api/#operation/identify)
      * @param traits Map of <String, Any> to be added
      */
-    fun identify(userId: String, traits: CustomAttributes) {
path: datapipelines/src/main/kotlin/io/customer/sdk/DataPipelineInstance.kt
user: login: Shahroz16
body: its showing up now 
created_at: 2025-04-10T14:04:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-08T09:38:29Z
body: <!-- sample app builds --> 
# Sample app builds 📱

Below you will find the list of the latest versions of the sample apps. It's recommended to always download the latest builds of the sample apps to accurately test the pull request. 

---
author_association: NONE
user: login: codecov[bot]
created_at: 2025-04-08T09:40:07Z
body: ## [Codecov](https://app.codecov.io/gh/customerio/customerio-android/pull/508?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 52.45%. Comparing base [(`26b4621`)](https://app.codecov.io/gh/customerio/customerio-android/commit/26b4621f21365d8fae9b03c093cef1b3dba67f9e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio) to head [(`5adacc4`)](https://app.codecov.io/gh/customerio/customerio-android/commit/5adacc4090049b46cb861d9d462ad8cb1dce68cf?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio).
> Report is 5 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff              @@
##               main     #508      +/-   ##
============================================
+ Coverage     52.02%   52.45%   +0.43%     
- Complexity      318      319       +1     
============================================
  Files           105      104       -1     
  Lines          2862     2888      +26     
  Branches        381      384       +3     
============================================
+ Hits           1489     1515      +26     
  Misses         1259     1259              
  Partials        114      114              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/customerio/customerio-android/pull/508?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-08T09:42:11Z
body: Build available to test
Version: `mbl-955-fix-nested-null-parsing-SNAPSHOT`
Repository: `https://s01.oss.sonatype.org/content/repositories/snapshots/`
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-08T09:42:25Z
body: * java_layout: `mbl-955-fix-nested-null-parsing (1744105183)`
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-08T09:43:03Z
body: * kotlin_compose: `mbl-955-fix-nested-null-parsing (1744105171)`
author_association: NONE
user: login: Shahroz16
created_at: 2025-04-09T21:08:09Z
body: > * I cross tested with iOS quickly and it seems like iOS omits the value all together rather than sending it null, but it's not consistent, have we cross checked behavior with iOS?

Thats a good callout, I agree with consistency but I also don't want to introduce a breaking change in terms of behavior. For example, currently `null` in the parent are treated as JsonNull and not skipped. 

If we add logic of skipping nulls, then even that current behavior is going to be changed and if anyone has any segments/events driven based on that current behavior, it could break for them
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-10T14:07:17Z
body: * java_layout: `mbl-955-fix-nested-null-parsing (1744293869)`
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-10T14:08:01Z
body: * kotlin_compose: `mbl-955-fix-nested-null-parsing (1744293885)`
author_association: NONE