Hi - we are running into this issue in our production usage of Segment via Avo. Properties that are objects/lists with a null property/element throw an exception when serialized, so the event is dropped. We are going to filter these out before sending to Segment as a stop-gap, but seemed worth reporting due to the type erasure.
SDK version: com.segment.analytics.kotlin:core — reproduces on 1.14.2 through 1.25.0 (latest); the relevant code is unchanged on main.
Description
Analytics.track(name, properties) throws a NullPointerException when the properties contain a null value nested inside a Map or Collection (e.g. a list of maps where one map value is null). A null at the top level of the properties map serializes fine — only nested nulls crash. The throw happens synchronously inside track() (during Json.encodeToJsonElement), so it propagates to the caller rather than failing quietly on the analytics thread.
Minimal reproduction
// Top-level null — works fine (serialized via kotlinx NullableSerializer)
analytics.track("ok", mapOf("a" to null))
// Null nested inside a list-of-maps — throws NullPointerException
analytics.track("crash", mapOf(
"items" to listOf(
mapOf("id" to 1, "updated_at" to null) // null map value one level down
)
))
// Equivalently, a null element directly in a collection also throws
analytics.track("crash2", mapOf("tags" to listOf("a", null, "b")))
Stack trace
java.lang.NullPointerException: Attempt to invoke virtual method
'java.lang.Class java.lang.Object.getClass()' on a null object reference
at com.segment.analytics.kotlin.core.utilities.JsonUtils.toJsonElement(JSON.kt)
at com.segment.analytics.kotlin.core.utilities.AnySerializer.serialize(AnySerializer.kt:22)
at kotlinx.serialization.json.internal.AbstractJsonTreeEncoder.encodeSerializableValue(TreeJsonEncoder.kt:305)
at kotlinx.serialization.internal.NullableSerializer.serialize(NullableSerializer.kt:23)
at kotlinx.serialization.internal.MapLikeSerializer.serialize(CollectionSerializers.kt:123)
Root cause
The top level is null-safe, but nested values are not, and the gap is opened by an unchecked cast over erased generics.
At the top level, the properties map's values are serialized through NullableSerializer(AnySerializer), which explicitly handles null. That's why mapOf("a" to null) works.
Once AnySerializer is handed a non-null container, it switches to the hand-rolled Any.toJsonElement() extensions. In core/.../utilities/JSON.kt, the dispatcher casts away the element-type nullability:
fun Any.toJsonElement(): JsonElement {
when (this) {
is Map<*, *> -> { val value = this as Map<String, Any>; return value.toJsonElement() } // unchecked cast
is Collection<*> -> { val value = this as Collection<Any>; return value.toJsonElement() } // unchecked cast
is Array<*> -> { val value = this as Array<Any>; return value.toJsonElement() } // unchecked cast
...
else -> { serializerFor(this::class)?.let { return Json.encodeToJsonElement(it, this) } }
}
return JsonNull
}
A real Map<String, Any?> containing a null value passes the is Map<*, *> check, then this as Map<String, Any> is an unchecked cast: due to type erasure the <String, Any> arguments aren't verified at runtime, so a map full of nulls is now statically typed Map<String, Any> with no runtime check (compiler emits only an "unchecked cast" warning).
The container overloads then recurse without a null check, because their elements are now statically Any (non-null) — so the compiler inserts no null guard:
fun Map<String, Any>.toJsonElement(): JsonElement = buildJsonObject {
for ((key, value) in this@toJsonElement) {
if (value is JsonElement) put(key, value)
else put(key, value.toJsonElement()) // `value` is statically Any but may actually be null
}
}
fun Collection<Any>.toJsonElement(): JsonArray = buildJsonArray {
for (item in this@toJsonElement) {
if (item is JsonElement) add(item)
else add(item.toJsonElement()) // same: statically Any, may be null
}
}
The actual null reaches Any.toJsonElement() as the receiver. The when(this) is checks are all false for null, so it falls to else -> serializerFor(this::class), and this::class compiles to this.getClass() → NPE on a null receiver.
So the crash isn't a normal nullable-value flow — it's a null that the type system was told (via the unchecked cast) couldn't exist, slipping through to the first actual dereference (getClass()). The Map/Collection/Array extensions were introduced in #168.
Expected behavior
A nested null should serialize as JsonNull, consistent with how top-level nulls are already handled by NullableSerializer — not throw.
Suggested fix
1. Primary — null-guard centrally in Any.toJsonElement() by making the receiver nullable and short-circuiting:
fun Any?.toJsonElement(): JsonElement {
if (this == null) return JsonNull
when (this) {
is Map<*, *> -> ...
...
}
}
This covers every nested case at once (map values, collection elements, array elements, Pair/Triple/Map.Entry), since they all ultimately call x.toJsonElement() on a possibly-null x. It's source-compatible (a T?-receiver extension can be called on a non-null receiver) and binary-compatible (the JVM descriptor is (Ljava/lang/Object;)… either way — receiver nullability is only an annotation). Returning JsonNull (rather than dropping the entry) keeps the serializer consistent with the top-level NullableSerializer behavior.
2. Optional cleanup — remove the unchecked casts where it's safe. Map (Map<K, out V>) and Collection (Collection<out E>) are covariant, so the overloads can be widened to Map<String, Any?> / Collection<Any?> and the casts dropped; covariance + erasure makes that source- and binary-compatible. Note Array<T> is invariant, so its overload can't be widened the same way — leave the Array cast in place and rely on fix #1 for it.
Related
Distinct from #291 (ClassCastException with MutableMap) and #299 (nested JsonObject serialized as empty). Not previously reported.
Hi - we are running into this issue in our production usage of Segment via Avo. Properties that are objects/lists with a null property/element throw an exception when serialized, so the event is dropped. We are going to filter these out before sending to Segment as a stop-gap, but seemed worth reporting due to the type erasure.
SDK version:
com.segment.analytics.kotlin:core— reproduces on 1.14.2 through 1.25.0 (latest); the relevant code is unchanged onmain.Description
Analytics.track(name, properties)throws aNullPointerExceptionwhen the properties contain anullvalue nested inside aMaporCollection(e.g. a list of maps where one map value isnull). Anullat the top level of the properties map serializes fine — only nested nulls crash. The throw happens synchronously insidetrack()(duringJson.encodeToJsonElement), so it propagates to the caller rather than failing quietly on the analytics thread.Minimal reproduction
Stack trace
Root cause
The top level is null-safe, but nested values are not, and the gap is opened by an unchecked cast over erased generics.
At the top level, the properties map's values are serialized through
NullableSerializer(AnySerializer), which explicitly handlesnull. That's whymapOf("a" to null)works.Once
AnySerializeris handed a non-null container, it switches to the hand-rolledAny.toJsonElement()extensions. Incore/.../utilities/JSON.kt, the dispatcher casts away the element-type nullability:A real
Map<String, Any?>containing anullvalue passes theis Map<*, *>check, thenthis as Map<String, Any>is an unchecked cast: due to type erasure the<String, Any>arguments aren't verified at runtime, so a map full of nulls is now statically typedMap<String, Any>with no runtime check (compiler emits only an "unchecked cast" warning).The container overloads then recurse without a null check, because their elements are now statically
Any(non-null) — so the compiler inserts no null guard:The actual
nullreachesAny.toJsonElement()as the receiver. Thewhen(this)ischecks are allfalsefornull, so it falls toelse -> serializerFor(this::class), andthis::classcompiles tothis.getClass()→ NPE on a null receiver.So the crash isn't a normal nullable-value flow — it's a null that the type system was told (via the unchecked cast) couldn't exist, slipping through to the first actual dereference (
getClass()). TheMap/Collection/Arrayextensions were introduced in #168.Expected behavior
A nested
nullshould serialize asJsonNull, consistent with how top-level nulls are already handled byNullableSerializer— not throw.Suggested fix
1. Primary — null-guard centrally in
Any.toJsonElement()by making the receiver nullable and short-circuiting:This covers every nested case at once (map values, collection elements, array elements,
Pair/Triple/Map.Entry), since they all ultimately callx.toJsonElement()on a possibly-nullx. It's source-compatible (aT?-receiver extension can be called on a non-null receiver) and binary-compatible (the JVM descriptor is(Ljava/lang/Object;)…either way — receiver nullability is only an annotation). ReturningJsonNull(rather than dropping the entry) keeps the serializer consistent with the top-levelNullableSerializerbehavior.2. Optional cleanup — remove the unchecked casts where it's safe.
Map(Map<K, out V>) andCollection(Collection<out E>) are covariant, so the overloads can be widened toMap<String, Any?>/Collection<Any?>and the casts dropped; covariance + erasure makes that source- and binary-compatible. NoteArray<T>is invariant, so its overload can't be widened the same way — leave theArraycast in place and rely on fix #1 for it.Related
Distinct from #291 (
ClassCastExceptionwithMutableMap) and #299 (nestedJsonObjectserialized as empty). Not previously reported.