From eab46310f48b606c69cbe9f5d946b0e75d28b692 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 22 Oct 2026 13:41:10 +0200 Subject: [PATCH 1/6] fix: serialize non-finite event properties as null --- .changeset/plain-forks-design.md | 5 ++ src/PostHog/Json/JsonSerializerHelper.cs | 2 + .../Json/NonFiniteDoubleJsonConverter.cs | 22 +++++ .../Json/NonFiniteSingleJsonConverter.cs | 22 +++++ .../Json/JsonSerializerHelperTests.cs | 84 +++++++++++++++++++ tests/UnitTests/PostHogClientTests.cs | 76 +++++++++++++++++ 6 files changed, 211 insertions(+) create mode 100644 .changeset/plain-forks-design.md create mode 100644 src/PostHog/Json/NonFiniteDoubleJsonConverter.cs create mode 100644 src/PostHog/Json/NonFiniteSingleJsonConverter.cs diff --git a/.changeset/plain-forks-design.md b/.changeset/plain-forks-design.md new file mode 100644 index 00000000..133a4e33 --- /dev/null +++ b/.changeset/plain-forks-design.md @@ -0,0 +1,5 @@ +--- +"PostHog": patch +--- + +Serialize non-finite float and double event properties as JSON null so they do not prevent an event batch from being sent. diff --git a/src/PostHog/Json/JsonSerializerHelper.cs b/src/PostHog/Json/JsonSerializerHelper.cs index bc9fc5bb..f60c5b05 100644 --- a/src/PostHog/Json/JsonSerializerHelper.cs +++ b/src/PostHog/Json/JsonSerializerHelper.cs @@ -11,6 +11,8 @@ internal static class JsonSerializerHelper PropertyNameCaseInsensitive = true, Converters = { + new NonFiniteDoubleJsonConverter(), + new NonFiniteSingleJsonConverter(), new ReadOnlyCollectionJsonConverterFactory(), new ReadOnlyDictionaryJsonConverterFactory() } diff --git a/src/PostHog/Json/NonFiniteDoubleJsonConverter.cs b/src/PostHog/Json/NonFiniteDoubleJsonConverter.cs new file mode 100644 index 00000000..81d6a956 --- /dev/null +++ b/src/PostHog/Json/NonFiniteDoubleJsonConverter.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PostHog.Json; + +internal sealed class NonFiniteDoubleJsonConverter : JsonConverter +{ + public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.GetDouble(); + + public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + { + writer.WriteNullValue(); + } + else + { + writer.WriteNumberValue(value); + } + } +} diff --git a/src/PostHog/Json/NonFiniteSingleJsonConverter.cs b/src/PostHog/Json/NonFiniteSingleJsonConverter.cs new file mode 100644 index 00000000..d1a7641d --- /dev/null +++ b/src/PostHog/Json/NonFiniteSingleJsonConverter.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PostHog.Json; + +internal sealed class NonFiniteSingleJsonConverter : JsonConverter +{ + public override float Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.GetSingle(); + + public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOptions options) + { + if (float.IsNaN(value) || float.IsInfinity(value)) + { + writer.WriteNullValue(); + } + else + { + writer.WriteNumberValue(value); + } + } +} diff --git a/tests/UnitTests/Json/JsonSerializerHelperTests.cs b/tests/UnitTests/Json/JsonSerializerHelperTests.cs index 68f7c7a5..ceaafb5a 100644 --- a/tests/UnitTests/Json/JsonSerializerHelperTests.cs +++ b/tests/UnitTests/Json/JsonSerializerHelperTests.cs @@ -2,11 +2,95 @@ using PostHog.Json; using PostHog.Library; using System.Collections.ObjectModel; +using System.Text.Json; namespace JsonSerializerHelperTests; public class TheSerializeToCamelCaseJsonMethod { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SerializesTypedNonFiniteNumbersAsNull(bool writeIndented) + { + var obj = new + { + Doubles = new[] { double.NaN, double.PositiveInfinity, double.NegativeInfinity, double.MaxValue, double.MinValue, 0.0 }, + Singles = new[] { float.NaN, float.PositiveInfinity, float.NegativeInfinity, float.MaxValue, float.MinValue, 0.0f }, + Nested = new { Double = double.NaN, Single = float.PositiveInfinity }, + NullableDouble = (double?)double.NegativeInfinity, + NullableSingle = (float?)float.NaN, + Null = (double?)null + }; + + var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(obj, writeIndented); + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + var doubles = root.GetProperty("doubles"); + var singles = root.GetProperty("singles"); + Assert.Equal(6, doubles.GetArrayLength()); + Assert.Equal(6, singles.GetArrayLength()); + for (var i = 0; i < 3; i++) + { + Assert.Equal(JsonValueKind.Null, doubles[i].ValueKind); + Assert.Equal(JsonValueKind.Null, singles[i].ValueKind); + } + Assert.Equal(double.MaxValue, doubles[3].GetDouble()); + Assert.Equal(double.MinValue, doubles[4].GetDouble()); + Assert.Equal(0.0, doubles[5].GetDouble()); + Assert.Equal(float.MaxValue, singles[3].GetSingle()); + Assert.Equal(float.MinValue, singles[4].GetSingle()); + Assert.Equal(0.0f, singles[5].GetSingle()); + JsonAssert.Equal("""{"double":null,"single":null}""", root.GetProperty("nested").GetRawText()); + Assert.Equal(JsonValueKind.Null, root.GetProperty("nullableDouble").ValueKind); + Assert.Equal(JsonValueKind.Null, root.GetProperty("nullableSingle").ValueKind); + Assert.Equal(JsonValueKind.Null, root.GetProperty("null").ValueKind); + } + + [Fact] + public async Task StillSupportsFiniteNumericDictionaryKeys() + { + var doubles = new Dictionary { [1.25] = 2.5 }; + var singles = new Dictionary { [1.5f] = 3.5f }; + + var doubleJson = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(doubles); + var singleJson = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(singles); + + JsonAssert.Equal("""{"1.25":2.5}""", doubleJson); + JsonAssert.Equal("""{"1.5":3.5}""", singleJson); + Assert.Equal(doubles, await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync>(doubleJson)); + Assert.Equal(singles, await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync>(singleJson)); + } + + [Fact] + public async Task StillDeserializesFiniteNumbers() + { + var doubles = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync("[1.25,-2.5,0]"); + var singles = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync("[1.25,-2.5,0]"); + + Assert.Equal(new[] { 1.25, -2.5, 0.0 }, doubles); + Assert.Equal(new[] { 1.25f, -2.5f, 0.0f }, singles); + } + + [Theory] + [InlineData("\"NaN\"")] + [InlineData("\"Infinity\"")] + [InlineData("\"-Infinity\"")] + public async Task StillRejectsNamedFloatingPointStrings(string json) + { + await Assert.ThrowsAsync(() => JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync(json)); + await Assert.ThrowsAsync(() => JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync(json)); + } + + [Fact] + public async Task StillRejectsUnsupportedValues() + { + var obj = new { Callback = (Action)(() => { }) }; + + await Assert.ThrowsAsync(() => JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(obj)); + } + [Fact] public async Task ShouldSerializeObjectToCamelCaseJson() { diff --git a/tests/UnitTests/PostHogClientTests.cs b/tests/UnitTests/PostHogClientTests.cs index 2c4bba45..4389d7cf 100644 --- a/tests/UnitTests/PostHogClientTests.cs +++ b/tests/UnitTests/PostHogClientTests.cs @@ -1,4 +1,5 @@ #pragma warning disable CS0618 // Tests/samples retain coverage of the deprecated single-flag API surface. +using System.IO.Compression; using System.Net; using System.Reflection; using System.Runtime.InteropServices; @@ -419,6 +420,81 @@ static JsonElement GetOnlyBatchItem(FakeHttpMessageHandler.RequestHandler batchH public class TheCaptureMethod { + [Theory] + [InlineData(double.NaN, false)] + [InlineData(double.PositiveInfinity, false)] + [InlineData(double.NegativeInfinity, false)] + [InlineData(float.NaN, false)] + [InlineData(float.PositiveInfinity, false)] + [InlineData(float.NegativeInfinity, false)] + [InlineData(double.NaN, true)] + [InlineData(double.PositiveInfinity, true)] + [InlineData(double.NegativeInfinity, true)] + [InlineData(float.NaN, true)] + [InlineData(float.PositiveInfinity, true)] + [InlineData(float.NegativeInfinity, true)] + public async Task SendsNonFinitePropertiesAsNullWithNeighboringEvent(object value, bool enableCompression) + { + var container = new TestContainer(services => services.Configure(options => + { + options.EnableCompression = enableCompression; + })); + string? body = null; + using var response = new MemoryStream(Encoding.UTF8.GetBytes("{\"status\":1}")); + var requestHandler = container.FakeHttpMessageHandler.AddStreamResponse(async request => + { + Assert.Equal("/batch", request.RequestUri!.AbsolutePath); + Assert.Equal(enableCompression, request.Content!.Headers.ContentEncoding.Contains("gzip")); + using var stream = await request.Content.ReadAsStreamAsync(); + using var decoded = enableCompression ? new GZipStream(stream, CompressionMode.Decompress) : stream; + using var reader = new StreamReader(decoded); + body = await reader.ReadToEndAsync(); + return true; + }, response); + using var client = container.Activate(); + var nested = new Dictionary { ["value"] = value, ["finite"] = 2.5 }; + var array = new object[] { 1, value, new { Value = value, Finite = 3.5f }, "NaN", null! }; + var properties = new Dictionary + { + ["value"] = value, + ["nested"] = nested, + ["array"] = array, + ["finiteDouble"] = 1.25, + ["finiteSingle"] = 1.5f, + ["enabled"] = true, + ["nothing"] = null!, + ["literal"] = "Infinity" + }; + + Assert.True(client.Capture("test-user", "non-finite", properties)); + Assert.True(client.Capture("test-user", "neighbor", new Dictionary { ["finite"] = 42 })); + await client.FlushAsync(); + + Assert.Single(requestHandler.ReceivedRequests); + Assert.NotNull(body); + using var document = JsonDocument.Parse(body); + var batch = document.RootElement.GetProperty("batch"); + Assert.Equal(2, batch.GetArrayLength()); + Assert.Equal("non-finite", batch[0].GetProperty("event").GetString()); + var sent = batch[0].GetProperty("properties"); + Assert.Equal(JsonValueKind.Null, sent.GetProperty("value").ValueKind); + JsonAssert.Equal("""{"value":null,"finite":2.5}""", sent.GetProperty("nested").GetRawText()); + JsonAssert.Equal("""[1,null,{"value":null,"finite":3.5},"NaN",null]""", sent.GetProperty("array").GetRawText()); + Assert.Equal(1.25, sent.GetProperty("finiteDouble").GetDouble()); + Assert.Equal(1.5f, sent.GetProperty("finiteSingle").GetSingle()); + Assert.True(sent.GetProperty("enabled").GetBoolean()); + Assert.Equal(JsonValueKind.Null, sent.GetProperty("nothing").ValueKind); + Assert.Equal("Infinity", sent.GetProperty("literal").GetString()); + Assert.Equal("neighbor", batch[1].GetProperty("event").GetString()); + Assert.Equal(42, batch[1].GetProperty("properties").GetProperty("finite").GetInt32()); + Assert.Same(value, properties["value"]); + Assert.Same(value, nested["value"]); + Assert.Same(value, array[1]); + Assert.Equal(new { Value = value, Finite = 3.5f }, array[2]); + Assert.Equal(5, array.Length); + Assert.Null(array[4]); + } + [Fact] public async Task DoesNotMutateOrRetainProvidedProperties() { From 0539058c70c357bdc764eed0bdcc498595292677 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 22 Oct 2026 15:44:06 +0200 Subject: [PATCH 2/6] fix: preserve number handling and isolate compression tests --- .changeset/plain-forks-design.md | 2 + src/PostHog/Json/JsonSerializerHelper.cs | 1 + .../Json/NonFiniteDoubleJsonConverter.cs | 8 +- .../Json/NonFiniteSingleJsonConverter.cs | 8 +- .../NumberHandlingJsonTypeInfoResolver.cs | 80 ++++++++ .../Json/JsonSerializerHelperTests.cs | 172 ++++++++++++++++++ .../Library/HttpClientExtensionsTests.cs | 4 + tests/UnitTests/PostHogClientTests.cs | 8 +- 8 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs diff --git a/.changeset/plain-forks-design.md b/.changeset/plain-forks-design.md index 133a4e33..a00dd947 100644 --- a/.changeset/plain-forks-design.md +++ b/.changeset/plain-forks-design.md @@ -1,5 +1,7 @@ --- "PostHog": patch +"PostHog.AspNetCore": patch +"PostHog.AI": patch --- Serialize non-finite float and double event properties as JSON null so they do not prevent an event batch from being sent. diff --git a/src/PostHog/Json/JsonSerializerHelper.cs b/src/PostHog/Json/JsonSerializerHelper.cs index f60c5b05..4fc438b9 100644 --- a/src/PostHog/Json/JsonSerializerHelper.cs +++ b/src/PostHog/Json/JsonSerializerHelper.cs @@ -9,6 +9,7 @@ internal static class JsonSerializerHelper { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, + TypeInfoResolver = new NumberHandlingJsonTypeInfoResolver(), Converters = { new NonFiniteDoubleJsonConverter(), diff --git a/src/PostHog/Json/NonFiniteDoubleJsonConverter.cs b/src/PostHog/Json/NonFiniteDoubleJsonConverter.cs index 81d6a956..a37e9fca 100644 --- a/src/PostHog/Json/NonFiniteDoubleJsonConverter.cs +++ b/src/PostHog/Json/NonFiniteDoubleJsonConverter.cs @@ -6,7 +6,9 @@ namespace PostHog.Json; internal sealed class NonFiniteDoubleJsonConverter : JsonConverter { public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => reader.GetDouble(); + => reader.TokenType == JsonTokenType.Number + ? reader.GetDouble() + : JsonSerializer.Deserialize(ref reader, NumberHandlingJsonTypeInfoResolver.GetBuiltInOptions(options.NumberHandling)); public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options) { @@ -14,6 +16,10 @@ public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOp { writer.WriteNullValue(); } + else if ((options.NumberHandling & JsonNumberHandling.WriteAsString) != 0) + { + JsonSerializer.Serialize(writer, value, NumberHandlingJsonTypeInfoResolver.GetBuiltInOptions(options.NumberHandling)); + } else { writer.WriteNumberValue(value); diff --git a/src/PostHog/Json/NonFiniteSingleJsonConverter.cs b/src/PostHog/Json/NonFiniteSingleJsonConverter.cs index d1a7641d..5c39a031 100644 --- a/src/PostHog/Json/NonFiniteSingleJsonConverter.cs +++ b/src/PostHog/Json/NonFiniteSingleJsonConverter.cs @@ -6,7 +6,9 @@ namespace PostHog.Json; internal sealed class NonFiniteSingleJsonConverter : JsonConverter { public override float Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => reader.GetSingle(); + => reader.TokenType == JsonTokenType.Number + ? reader.GetSingle() + : JsonSerializer.Deserialize(ref reader, NumberHandlingJsonTypeInfoResolver.GetBuiltInOptions(options.NumberHandling)); public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOptions options) { @@ -14,6 +16,10 @@ public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOpt { writer.WriteNullValue(); } + else if ((options.NumberHandling & JsonNumberHandling.WriteAsString) != 0) + { + JsonSerializer.Serialize(writer, value, NumberHandlingJsonTypeInfoResolver.GetBuiltInOptions(options.NumberHandling)); + } else { writer.WriteNumberValue(value); diff --git a/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs b/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs new file mode 100644 index 00000000..7ebf0a84 --- /dev/null +++ b/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs @@ -0,0 +1,80 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace PostHog.Json; + +internal sealed class NumberHandlingJsonTypeInfoResolver(JsonNumberHandling? inheritedNumberHandling = null) : DefaultJsonTypeInfoResolver +{ + readonly JsonNumberHandling? _inheritedNumberHandling = inheritedNumberHandling; + static readonly JsonSerializerOptions[] BuiltInOptions = Enumerable.Range(0, 8) + .Select(value => new JsonSerializerOptions { NumberHandling = (JsonNumberHandling)value }).ToArray(); + + internal static JsonSerializerOptions GetBuiltInOptions(JsonNumberHandling handling) => BuiltInOptions[(int)handling]; + + public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) + { + var typeInfo = base.GetTypeInfo(type, options); + if (typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary + && IsAffected(typeInfo) && typeInfo.NumberHandling is { } typeHandling && typeHandling != options.NumberHandling) + { + return (JsonTypeInfo)typeof(NumberHandlingJsonTypeInfoResolver) + .GetMethod(nameof(CreateTypeInfo), BindingFlags.Static | BindingFlags.NonPublic)! + .MakeGenericMethod(type).Invoke(null, [options, WithNumberHandling(options, typeHandling)])!; + } + + if (typeInfo.Kind != JsonTypeInfoKind.Object) + { + return typeInfo; + } + + // A member's number handling applies to collection elements, not members of nested models. + typeInfo.NumberHandling ??= _inheritedNumberHandling; + foreach (var property in typeInfo.Properties) + { + if (property.CustomConverter is not null) + { + continue; + } + var propertyTypeInfo = base.GetTypeInfo(property.PropertyType, options); + var handling = property.NumberHandling ?? typeInfo.NumberHandling ?? propertyTypeInfo.NumberHandling ?? options.NumberHandling; + if (!IsAffected(propertyTypeInfo) || handling == options.NumberHandling) + { + continue; + } + + var converterType = typeof(NumberHandlingJsonConverter<>).MakeGenericType(property.PropertyType); + property.CustomConverter = (JsonConverter)Activator.CreateInstance(converterType, WithNumberHandling(options, handling))!; + property.NumberHandling = null; + } + return typeInfo; + } + + static bool IsAffected(JsonTypeInfo typeInfo) + { + var type = typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary ? typeInfo.ElementType! : typeInfo.Type; + type = Nullable.GetUnderlyingType(type) ?? type; + return type == typeof(double) || type == typeof(float) || type == typeof(object); + } + + static JsonSerializerOptions WithNumberHandling(JsonSerializerOptions options, JsonNumberHandling handling) + => new(options) + { + NumberHandling = handling, + TypeInfoResolver = new NumberHandlingJsonTypeInfoResolver( + (options.TypeInfoResolver as NumberHandlingJsonTypeInfoResolver)?._inheritedNumberHandling ?? options.NumberHandling) + }; + + static JsonTypeInfo CreateTypeInfo(JsonSerializerOptions options, JsonSerializerOptions numberOptions) + => JsonMetadataServices.CreateValueInfo(options, new NumberHandlingJsonConverter(numberOptions)); +} + +internal sealed class NumberHandlingJsonConverter(JsonSerializerOptions numberOptions) : JsonConverter +{ + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => JsonSerializer.Deserialize(ref reader, numberOptions); + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + => JsonSerializer.Serialize(writer, value, numberOptions); +} diff --git a/tests/UnitTests/Json/JsonSerializerHelperTests.cs b/tests/UnitTests/Json/JsonSerializerHelperTests.cs index ceaafb5a..17ec4f4a 100644 --- a/tests/UnitTests/Json/JsonSerializerHelperTests.cs +++ b/tests/UnitTests/Json/JsonSerializerHelperTests.cs @@ -3,6 +3,7 @@ using PostHog.Library; using System.Collections.ObjectModel; using System.Text.Json; +using System.Text.Json.Serialization; namespace JsonSerializerHelperTests; @@ -91,6 +92,177 @@ public async Task StillRejectsUnsupportedValues() await Assert.ThrowsAsync(() => JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(obj)); } + [Fact] + public async Task PreservesNumberHandlingOnFiniteModelMembers() + { + var model = new StringNumberModel { Measurement = 1.25, Score = 2.5f }; + + var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model); + + JsonAssert.Equal("""{"measurement":"1.25","score":"2.5"}""", json); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public async Task WritesAttributedNonFiniteNumbersAsNull(double value) + { + var model = new NumberHandlingModel + { + Measurement = value, + Score = (float)value, + Readings = [value, 1.25, null], + Samples = [(float)value, 2.5f, null] + }; + + var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model); + + JsonAssert.Equal("""{"measurement":null,"score":null,"readings":[null,"1.25",null],"samples":[null,2.5,null],"count":3}""", json); + } + + [Fact] + public async Task PreservesTypeAndCollectionNumberHandling() + { + var model = new NumberHandlingModel { Measurement = 1.25, Score = 2.5f, Readings = [3.5, null], Samples = [4.5f, null] }; + + var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model); + var result = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync(json); + var listJson = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(new StringNumberList { 1.25, double.NaN, null }); + var list = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync("""["1.25",null]"""); + var map = new StringNumberMap { ["finite"] = 1.25f, ["nonFinite"] = float.PositiveInfinity }; + var mapJson = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(map); + var restoredMap = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync("""{"finite":"1.25","nothing":null}"""); + + JsonAssert.Equal("""{"measurement":"1.25","score":"2.5","readings":["3.5",null],"samples":[4.5,null],"count":3}""", json); + Assert.NotNull(result); + Assert.Equal(model.Measurement, result.Measurement); + Assert.Equal(model.Score, result.Score); + Assert.Equal(model.Readings, result.Readings); + Assert.Equal(model.Samples, result.Samples); + JsonAssert.Equal("""["1.25",null,null]""", listJson); + Assert.Equal(new double?[] { 1.25, null }, list); + JsonAssert.Equal("""{"finite":"1.25","nonFinite":null}""", mapJson); + Assert.Equal(new StringNumberMap { ["finite"] = 1.25f, ["nothing"] = null }, restoredMap); + } + + [Theory] + [InlineData(JsonNumberHandling.Strict, "[1.25,2.5]", "[1.25,2.5]")] + [InlineData(JsonNumberHandling.WriteAsString, "[\"1.25\",\"2.5\"]", "[1.25,2.5]")] + [InlineData(JsonNumberHandling.AllowReadingFromString, "[1.25,2.5]", "[\"1.25\",\"2.5\"]")] + [InlineData(JsonNumberHandling.AllowNamedFloatingPointLiterals, "[1.25,2.5]", "[1.25,2.5]")] + public void PreservesOptionsNumberHandling(JsonNumberHandling handling, string expected, string input) + { + var options = new JsonSerializerOptions(JsonSerializerHelper.Options) { NumberHandling = handling }; + + JsonAssert.Equal(expected, JsonSerializer.Serialize(new[] { 1.25, 2.5 }, options)); + JsonAssert.Equal(expected, JsonSerializer.Serialize(new[] { 1.25f, 2.5f }, options)); + Assert.Equal(new[] { 1.25, 2.5 }, JsonSerializer.Deserialize(input, options)); + Assert.Equal(new[] { 1.25f, 2.5f }, JsonSerializer.Deserialize(input, options)); + JsonAssert.Equal("[null,null]", JsonSerializer.Serialize(new[] { double.NaN, double.PositiveInfinity }, options)); + } + + [Theory] + [InlineData("NaN", double.NaN)] + [InlineData("Infinity", double.PositiveInfinity)] + [InlineData("-Infinity", double.NegativeInfinity)] + public async Task PreservesExplicitNamedNumberReads(string namedValue, double expected) + { + var json = "{\"measurement\":\"" + namedValue + "\",\"score\":\"" + namedValue + "\"}"; + + var model = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync(json); + var options = new JsonSerializerOptions(JsonSerializerHelper.Options) { NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals }; + + Assert.NotNull(model); + Assert.Equal(expected, model.Measurement); + Assert.Equal((float)expected, model.Score); + Assert.Equal(expected, JsonSerializer.Deserialize("\"" + namedValue + "\"", options)); + Assert.Equal((float)expected, JsonSerializer.Deserialize("\"" + namedValue + "\"", options)); + JsonAssert.Equal("""{"measurement":null,"score":null}""", await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model)); + } + + [Fact] + public async Task PreservesPlatformNumberHandlingForObjectMembers() + { + foreach (var value in new object[] { 1.25, 2.5f, new { Amount = 3.5 }, new object[] { 4.5, "NaN" } }) + { + var model = new ObjectNumberModel { Value = value }; + var expected = JsonSerializer.Serialize(model, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model); + + JsonAssert.Equal(expected, json); + } + } + + [Fact] + public async Task PreservesExplicitMemberConverter() + { + var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(new CustomNumberModel()); + + JsonAssert.Equal("""{"measurement":"custom"}""", json); + var model = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync(json); + Assert.NotNull(model); + Assert.Equal(1.25, model.Measurement); + } + + public sealed class StringNumberModel + { + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + public double Measurement { get; set; } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + public float Score { get; set; } + } + +#pragma warning disable CA1819, CA2227, CA1002 // Test models exercise array and mutable collection serialization. + [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] + public sealed class NumberHandlingModel + { + public double? Measurement { get; set; } + public float? Score { get; set; } + public double?[] Readings { get; set; } = []; + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public List Samples { get; set; } = []; + + [JsonNumberHandling(JsonNumberHandling.Strict)] + public double Count { get; set; } = 3; + } + +#pragma warning restore CA1819, CA2227, CA1002 + + [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] + public sealed class StringNumberList : List; + + [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] + public sealed class StringNumberMap : Dictionary; + + [JsonNumberHandling(JsonNumberHandling.AllowNamedFloatingPointLiterals)] + public sealed class NamedNumberModel + { + public double? Measurement { get; set; } + public float? Score { get; set; } + } + + public sealed class ObjectNumberModel + { + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + public object Value { get; set; } = 1.25; + } + + public sealed class CustomNumberModel + { + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + [JsonConverter(typeof(CustomNumberConverter))] + public double Measurement { get; set; } = 1.25; + } + + public sealed class CustomNumberConverter : JsonConverter + { + public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => 1.25; + public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options) => writer.WriteStringValue("custom"); + } + [Fact] public async Task ShouldSerializeObjectToCamelCaseJson() { diff --git a/tests/UnitTests/Library/HttpClientExtensionsTests.cs b/tests/UnitTests/Library/HttpClientExtensionsTests.cs index 5c2fd743..886cd638 100644 --- a/tests/UnitTests/Library/HttpClientExtensionsTests.cs +++ b/tests/UnitTests/Library/HttpClientExtensionsTests.cs @@ -1268,6 +1268,10 @@ await Assert.ThrowsAsync(() => } } +[CollectionDefinition(nameof(CompressionTestScope), DisableParallelization = true)] +public sealed class CompressionTestScope; + +[Collection(nameof(CompressionTestScope))] public class ThePostCompressedJsonAsyncMethod { static readonly Uri BatchUrl = new("https://us.i.posthog.com/batch"); diff --git a/tests/UnitTests/PostHogClientTests.cs b/tests/UnitTests/PostHogClientTests.cs index 4389d7cf..991904e9 100644 --- a/tests/UnitTests/PostHogClientTests.cs +++ b/tests/UnitTests/PostHogClientTests.cs @@ -445,8 +445,8 @@ public async Task SendsNonFinitePropertiesAsNullWithNeighboringEvent(object valu { Assert.Equal("/batch", request.RequestUri!.AbsolutePath); Assert.Equal(enableCompression, request.Content!.Headers.ContentEncoding.Contains("gzip")); - using var stream = await request.Content.ReadAsStreamAsync(); - using var decoded = enableCompression ? new GZipStream(stream, CompressionMode.Decompress) : stream; + using var stream = new MemoryStream(await request.Content.ReadAsByteArrayAsync()); + using Stream decoded = enableCompression ? new GZipStream(stream, CompressionMode.Decompress) : stream; using var reader = new StreamReader(decoded); body = await reader.ReadToEndAsync(); return true; @@ -463,7 +463,8 @@ public async Task SendsNonFinitePropertiesAsNullWithNeighboringEvent(object valu ["finiteSingle"] = 1.5f, ["enabled"] = true, ["nothing"] = null!, - ["literal"] = "Infinity" + ["literal"] = "Infinity", + ["model"] = new JsonSerializerHelperTests.TheSerializeToCamelCaseJsonMethod.StringNumberModel { Measurement = 1.25, Score = 2.5f } }; Assert.True(client.Capture("test-user", "non-finite", properties)); @@ -485,6 +486,7 @@ public async Task SendsNonFinitePropertiesAsNullWithNeighboringEvent(object valu Assert.True(sent.GetProperty("enabled").GetBoolean()); Assert.Equal(JsonValueKind.Null, sent.GetProperty("nothing").ValueKind); Assert.Equal("Infinity", sent.GetProperty("literal").GetString()); + JsonAssert.Equal("""{"measurement":"1.25","score":"2.5"}""", sent.GetProperty("model").GetRawText()); Assert.Equal("neighbor", batch[1].GetProperty("event").GetString()); Assert.Equal(42, batch[1].GetProperty("properties").GetProperty("finite").GetInt32()); Assert.Same(value, properties["value"]); From 3e8e7caae9f499a1c4993f6e97de4ffad7492082 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 22 Oct 2026 15:48:02 +0200 Subject: [PATCH 3/6] fix: preserve member number handling over collection attributes --- .../NumberHandlingJsonTypeInfoResolver.cs | 15 ++++++---- .../Json/JsonSerializerHelperTests.cs | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs b/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs index 7ebf0a84..211e063c 100644 --- a/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs +++ b/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs @@ -5,7 +5,7 @@ namespace PostHog.Json; -internal sealed class NumberHandlingJsonTypeInfoResolver(JsonNumberHandling? inheritedNumberHandling = null) : DefaultJsonTypeInfoResolver +internal sealed class NumberHandlingJsonTypeInfoResolver(JsonNumberHandling? inheritedNumberHandling = null, Type? memberType = null) : DefaultJsonTypeInfoResolver { readonly JsonNumberHandling? _inheritedNumberHandling = inheritedNumberHandling; static readonly JsonSerializerOptions[] BuiltInOptions = Enumerable.Range(0, 8) @@ -16,6 +16,10 @@ internal sealed class NumberHandlingJsonTypeInfoResolver(JsonNumberHandling? inh public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) { var typeInfo = base.GetTypeInfo(type, options); + if (type == memberType && typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary) + { + typeInfo.NumberHandling = options.NumberHandling; + } if (typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary && IsAffected(typeInfo) && typeInfo.NumberHandling is { } typeHandling && typeHandling != options.NumberHandling) { @@ -39,13 +43,14 @@ public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions option } var propertyTypeInfo = base.GetTypeInfo(property.PropertyType, options); var handling = property.NumberHandling ?? typeInfo.NumberHandling ?? propertyTypeInfo.NumberHandling ?? options.NumberHandling; - if (!IsAffected(propertyTypeInfo) || handling == options.NumberHandling) + if (!IsAffected(propertyTypeInfo) || (handling == options.NumberHandling + && (propertyTypeInfo.NumberHandling is null || propertyTypeInfo.NumberHandling == handling))) { continue; } var converterType = typeof(NumberHandlingJsonConverter<>).MakeGenericType(property.PropertyType); - property.CustomConverter = (JsonConverter)Activator.CreateInstance(converterType, WithNumberHandling(options, handling))!; + property.CustomConverter = (JsonConverter)Activator.CreateInstance(converterType, WithNumberHandling(options, handling, property.PropertyType))!; property.NumberHandling = null; } return typeInfo; @@ -58,12 +63,12 @@ static bool IsAffected(JsonTypeInfo typeInfo) return type == typeof(double) || type == typeof(float) || type == typeof(object); } - static JsonSerializerOptions WithNumberHandling(JsonSerializerOptions options, JsonNumberHandling handling) + static JsonSerializerOptions WithNumberHandling(JsonSerializerOptions options, JsonNumberHandling handling, Type? memberType = null) => new(options) { NumberHandling = handling, TypeInfoResolver = new NumberHandlingJsonTypeInfoResolver( - (options.TypeInfoResolver as NumberHandlingJsonTypeInfoResolver)?._inheritedNumberHandling ?? options.NumberHandling) + (options.TypeInfoResolver as NumberHandlingJsonTypeInfoResolver)?._inheritedNumberHandling ?? options.NumberHandling, memberType) }; static JsonTypeInfo CreateTypeInfo(JsonSerializerOptions options, JsonSerializerOptions numberOptions) diff --git a/tests/UnitTests/Json/JsonSerializerHelperTests.cs b/tests/UnitTests/Json/JsonSerializerHelperTests.cs index 17ec4f4a..c9b78981 100644 --- a/tests/UnitTests/Json/JsonSerializerHelperTests.cs +++ b/tests/UnitTests/Json/JsonSerializerHelperTests.cs @@ -181,6 +181,29 @@ public async Task PreservesExplicitNamedNumberReads(string namedValue, double ex JsonAssert.Equal("""{"measurement":null,"score":null}""", await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model)); } + [Theory] + [InlineData(JsonNumberHandling.Strict)] + [InlineData(JsonNumberHandling.WriteAsString)] + public void MemberNumberHandlingOverridesCollectionType(JsonNumberHandling optionsHandling) + { + var model = new MemberOverrideModel + { + Values = [1.25, double.NaN, null], + Metrics = new StringNumberMap { ["finite"] = 2.5f, ["nonFinite"] = float.PositiveInfinity } + }; + var options = new JsonSerializerOptions(JsonSerializerHelper.Options) { NumberHandling = optionsHandling }; + + var json = JsonSerializer.Serialize(model, options); + var result = JsonSerializer.Deserialize("""{"values":[1.25,null],"metrics":{"finite":2.5}}""", options); + + JsonAssert.Equal("""{"values":[1.25,null,null],"metrics":{"finite":2.5,"nonFinite":null}}""", json); + Assert.NotNull(result); + Assert.Equal(new double?[] { 1.25, null }, result.Values); + Assert.Equal(new StringNumberMap { ["finite"] = 2.5f }, result.Metrics); + Assert.Throws(() => JsonSerializer.Deserialize("""{"values":["1.25"]}""", options)); + Assert.Throws(() => JsonSerializer.Deserialize("""{"metrics":{"finite":"2.5"}}""", options)); + } + [Fact] public async Task PreservesPlatformNumberHandlingForObjectMembers() { @@ -229,6 +252,13 @@ public sealed class NumberHandlingModel public double Count { get; set; } = 3; } + [JsonNumberHandling(JsonNumberHandling.Strict)] + public sealed class MemberOverrideModel + { + [JsonNumberHandling(JsonNumberHandling.Strict)] + public StringNumberList Values { get; set; } = []; + public StringNumberMap Metrics { get; set; } = []; + } #pragma warning restore CA1819, CA2227, CA1002 [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] From 1dc25dc6535c1ab735d899a99ef7f446c47e1cd6 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 22 Oct 2026 15:55:59 +0200 Subject: [PATCH 4/6] fix: retain nested collection number handling attributes --- src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs | 5 +++++ tests/UnitTests/Json/JsonSerializerHelperTests.cs | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs b/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs index 211e063c..a6dac8f9 100644 --- a/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs +++ b/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs @@ -34,6 +34,7 @@ public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions option } // A member's number handling applies to collection elements, not members of nested models. + var usesInheritedHandling = typeInfo.NumberHandling is null; typeInfo.NumberHandling ??= _inheritedNumberHandling; foreach (var property in typeInfo.Properties) { @@ -42,6 +43,10 @@ public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions option continue; } var propertyTypeInfo = base.GetTypeInfo(property.PropertyType, options); + if (usesInheritedHandling && IsAffected(propertyTypeInfo)) + { + property.NumberHandling ??= propertyTypeInfo.NumberHandling; + } var handling = property.NumberHandling ?? typeInfo.NumberHandling ?? propertyTypeInfo.NumberHandling ?? options.NumberHandling; if (!IsAffected(propertyTypeInfo) || (handling == options.NumberHandling && (propertyTypeInfo.NumberHandling is null || propertyTypeInfo.NumberHandling == handling))) diff --git a/tests/UnitTests/Json/JsonSerializerHelperTests.cs b/tests/UnitTests/Json/JsonSerializerHelperTests.cs index c9b78981..9e779ea6 100644 --- a/tests/UnitTests/Json/JsonSerializerHelperTests.cs +++ b/tests/UnitTests/Json/JsonSerializerHelperTests.cs @@ -207,7 +207,11 @@ public void MemberNumberHandlingOverridesCollectionType(JsonNumberHandling optio [Fact] public async Task PreservesPlatformNumberHandlingForObjectMembers() { - foreach (var value in new object[] { 1.25, 2.5f, new { Amount = 3.5 }, new object[] { 4.5, "NaN" } }) + foreach (var value in new object[] + { + 1.25, 2.5f, new { Amount = 3.5, Count = 2 }, new object[] { 4.5, "NaN" }, + new { Values = new StringNumberList { 1.25 }, Metrics = new StringNumberMap { ["value"] = 2.5f } } + }) { var model = new ObjectNumberModel { Value = value }; var expected = JsonSerializer.Serialize(model, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); From f9aae49807365b4d5e0f00cffb91881e150d3df2 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 22 Oct 2026 16:36:24 +0200 Subject: [PATCH 5/6] fix: simplify non-finite number serialization after review --- src/PostHog/Json/JsonSerializerHelper.cs | 1 - .../Json/NonFiniteDoubleJsonConverter.cs | 6 +- .../Json/NonFiniteSingleJsonConverter.cs | 6 +- .../NumberHandlingJsonTypeInfoResolver.cs | 90 -------------- src/PostHog/README.md | 6 + .../Json/JsonSerializerHelperTests.cs | 116 ++---------------- .../Json/NonFiniteNumberCompatibilityTests.cs | 75 +++++++++++ tests/UnitTests/PostHogClientTests.cs | 2 +- 8 files changed, 100 insertions(+), 202 deletions(-) delete mode 100644 src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs create mode 100644 tests/UnitTests/Json/NonFiniteNumberCompatibilityTests.cs diff --git a/src/PostHog/Json/JsonSerializerHelper.cs b/src/PostHog/Json/JsonSerializerHelper.cs index 4fc438b9..f60c5b05 100644 --- a/src/PostHog/Json/JsonSerializerHelper.cs +++ b/src/PostHog/Json/JsonSerializerHelper.cs @@ -9,7 +9,6 @@ internal static class JsonSerializerHelper { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, - TypeInfoResolver = new NumberHandlingJsonTypeInfoResolver(), Converters = { new NonFiniteDoubleJsonConverter(), diff --git a/src/PostHog/Json/NonFiniteDoubleJsonConverter.cs b/src/PostHog/Json/NonFiniteDoubleJsonConverter.cs index a37e9fca..36a6afc7 100644 --- a/src/PostHog/Json/NonFiniteDoubleJsonConverter.cs +++ b/src/PostHog/Json/NonFiniteDoubleJsonConverter.cs @@ -5,10 +5,12 @@ namespace PostHog.Json; internal sealed class NonFiniteDoubleJsonConverter : JsonConverter { + static readonly JsonSerializerOptions[] BuiltInOptions = Enumerable.Range(0, 8) + .Select(value => new JsonSerializerOptions { NumberHandling = (JsonNumberHandling)value }).ToArray(); public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.TokenType == JsonTokenType.Number ? reader.GetDouble() - : JsonSerializer.Deserialize(ref reader, NumberHandlingJsonTypeInfoResolver.GetBuiltInOptions(options.NumberHandling)); + : JsonSerializer.Deserialize(ref reader, BuiltInOptions[(int)options.NumberHandling]); public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options) { @@ -18,7 +20,7 @@ public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOp } else if ((options.NumberHandling & JsonNumberHandling.WriteAsString) != 0) { - JsonSerializer.Serialize(writer, value, NumberHandlingJsonTypeInfoResolver.GetBuiltInOptions(options.NumberHandling)); + JsonSerializer.Serialize(writer, value, BuiltInOptions[(int)options.NumberHandling]); } else { diff --git a/src/PostHog/Json/NonFiniteSingleJsonConverter.cs b/src/PostHog/Json/NonFiniteSingleJsonConverter.cs index 5c39a031..dd9ea57b 100644 --- a/src/PostHog/Json/NonFiniteSingleJsonConverter.cs +++ b/src/PostHog/Json/NonFiniteSingleJsonConverter.cs @@ -5,10 +5,12 @@ namespace PostHog.Json; internal sealed class NonFiniteSingleJsonConverter : JsonConverter { + static readonly JsonSerializerOptions[] BuiltInOptions = Enumerable.Range(0, 8) + .Select(value => new JsonSerializerOptions { NumberHandling = (JsonNumberHandling)value }).ToArray(); public override float Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.TokenType == JsonTokenType.Number ? reader.GetSingle() - : JsonSerializer.Deserialize(ref reader, NumberHandlingJsonTypeInfoResolver.GetBuiltInOptions(options.NumberHandling)); + : JsonSerializer.Deserialize(ref reader, BuiltInOptions[(int)options.NumberHandling]); public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOptions options) { @@ -18,7 +20,7 @@ public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOpt } else if ((options.NumberHandling & JsonNumberHandling.WriteAsString) != 0) { - JsonSerializer.Serialize(writer, value, NumberHandlingJsonTypeInfoResolver.GetBuiltInOptions(options.NumberHandling)); + JsonSerializer.Serialize(writer, value, BuiltInOptions[(int)options.NumberHandling]); } else { diff --git a/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs b/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs deleted file mode 100644 index a6dac8f9..00000000 --- a/src/PostHog/Json/NumberHandlingJsonTypeInfoResolver.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Reflection; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; - -namespace PostHog.Json; - -internal sealed class NumberHandlingJsonTypeInfoResolver(JsonNumberHandling? inheritedNumberHandling = null, Type? memberType = null) : DefaultJsonTypeInfoResolver -{ - readonly JsonNumberHandling? _inheritedNumberHandling = inheritedNumberHandling; - static readonly JsonSerializerOptions[] BuiltInOptions = Enumerable.Range(0, 8) - .Select(value => new JsonSerializerOptions { NumberHandling = (JsonNumberHandling)value }).ToArray(); - - internal static JsonSerializerOptions GetBuiltInOptions(JsonNumberHandling handling) => BuiltInOptions[(int)handling]; - - public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) - { - var typeInfo = base.GetTypeInfo(type, options); - if (type == memberType && typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary) - { - typeInfo.NumberHandling = options.NumberHandling; - } - if (typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary - && IsAffected(typeInfo) && typeInfo.NumberHandling is { } typeHandling && typeHandling != options.NumberHandling) - { - return (JsonTypeInfo)typeof(NumberHandlingJsonTypeInfoResolver) - .GetMethod(nameof(CreateTypeInfo), BindingFlags.Static | BindingFlags.NonPublic)! - .MakeGenericMethod(type).Invoke(null, [options, WithNumberHandling(options, typeHandling)])!; - } - - if (typeInfo.Kind != JsonTypeInfoKind.Object) - { - return typeInfo; - } - - // A member's number handling applies to collection elements, not members of nested models. - var usesInheritedHandling = typeInfo.NumberHandling is null; - typeInfo.NumberHandling ??= _inheritedNumberHandling; - foreach (var property in typeInfo.Properties) - { - if (property.CustomConverter is not null) - { - continue; - } - var propertyTypeInfo = base.GetTypeInfo(property.PropertyType, options); - if (usesInheritedHandling && IsAffected(propertyTypeInfo)) - { - property.NumberHandling ??= propertyTypeInfo.NumberHandling; - } - var handling = property.NumberHandling ?? typeInfo.NumberHandling ?? propertyTypeInfo.NumberHandling ?? options.NumberHandling; - if (!IsAffected(propertyTypeInfo) || (handling == options.NumberHandling - && (propertyTypeInfo.NumberHandling is null || propertyTypeInfo.NumberHandling == handling))) - { - continue; - } - - var converterType = typeof(NumberHandlingJsonConverter<>).MakeGenericType(property.PropertyType); - property.CustomConverter = (JsonConverter)Activator.CreateInstance(converterType, WithNumberHandling(options, handling, property.PropertyType))!; - property.NumberHandling = null; - } - return typeInfo; - } - - static bool IsAffected(JsonTypeInfo typeInfo) - { - var type = typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary ? typeInfo.ElementType! : typeInfo.Type; - type = Nullable.GetUnderlyingType(type) ?? type; - return type == typeof(double) || type == typeof(float) || type == typeof(object); - } - - static JsonSerializerOptions WithNumberHandling(JsonSerializerOptions options, JsonNumberHandling handling, Type? memberType = null) - => new(options) - { - NumberHandling = handling, - TypeInfoResolver = new NumberHandlingJsonTypeInfoResolver( - (options.TypeInfoResolver as NumberHandlingJsonTypeInfoResolver)?._inheritedNumberHandling ?? options.NumberHandling, memberType) - }; - - static JsonTypeInfo CreateTypeInfo(JsonSerializerOptions options, JsonSerializerOptions numberOptions) - => JsonMetadataServices.CreateValueInfo(options, new NumberHandlingJsonConverter(numberOptions)); -} - -internal sealed class NumberHandlingJsonConverter(JsonSerializerOptions numberOptions) : JsonConverter -{ - public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => JsonSerializer.Deserialize(ref reader, numberOptions); - - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) - => JsonSerializer.Serialize(writer, value, numberOptions); -} diff --git a/src/PostHog/README.md b/src/PostHog/README.md index 7c181754..6893a03c 100644 --- a/src/PostHog/README.md +++ b/src/PostHog/README.md @@ -4,6 +4,12 @@ Please see the main [PostHog docs](https://posthog.com/docs). SDK usage examples and code snippets live in the official documentation so they stay up to date. +## Event property serialization + +Numeric `float` and `double` NaN and infinity values are sent as JSON `null`, including in nested objects and arrays. Finite values remain JSON numbers and literal strings are unchanged. + +The SDK's float/double converters do not apply `[JsonNumberHandling]` attributes on user models or their members, including collection elements. For example, `[JsonNumberHandling(WriteAsString)]` does not turn a finite double into a JSON string. Supply a string property value or an explicit `[JsonConverter]` when custom formatting is required. + ## Documentation - [.NET library docs](https://posthog.com/docs/libraries/dotnet) diff --git a/tests/UnitTests/Json/JsonSerializerHelperTests.cs b/tests/UnitTests/Json/JsonSerializerHelperTests.cs index 9e779ea6..ea2b1af1 100644 --- a/tests/UnitTests/Json/JsonSerializerHelperTests.cs +++ b/tests/UnitTests/Json/JsonSerializerHelperTests.cs @@ -93,13 +93,13 @@ public async Task StillRejectsUnsupportedValues() } [Fact] - public async Task PreservesNumberHandlingOnFiniteModelMembers() + public async Task WritesAttributedFloatingPointMembersAsNumbers() { var model = new StringNumberModel { Measurement = 1.25, Score = 2.5f }; var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model); - JsonAssert.Equal("""{"measurement":"1.25","score":"2.5"}""", json); + JsonAssert.Equal("""{"measurement":1.25,"score":2.5}""", json); } [Theory] @@ -108,42 +108,11 @@ public async Task PreservesNumberHandlingOnFiniteModelMembers() [InlineData(double.NegativeInfinity)] public async Task WritesAttributedNonFiniteNumbersAsNull(double value) { - var model = new NumberHandlingModel - { - Measurement = value, - Score = (float)value, - Readings = [value, 1.25, null], - Samples = [(float)value, 2.5f, null] - }; + var model = new StringNumberModel { Measurement = value, Score = (float)value }; var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model); - JsonAssert.Equal("""{"measurement":null,"score":null,"readings":[null,"1.25",null],"samples":[null,2.5,null],"count":3}""", json); - } - - [Fact] - public async Task PreservesTypeAndCollectionNumberHandling() - { - var model = new NumberHandlingModel { Measurement = 1.25, Score = 2.5f, Readings = [3.5, null], Samples = [4.5f, null] }; - - var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model); - var result = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync(json); - var listJson = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(new StringNumberList { 1.25, double.NaN, null }); - var list = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync("""["1.25",null]"""); - var map = new StringNumberMap { ["finite"] = 1.25f, ["nonFinite"] = float.PositiveInfinity }; - var mapJson = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(map); - var restoredMap = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync("""{"finite":"1.25","nothing":null}"""); - - JsonAssert.Equal("""{"measurement":"1.25","score":"2.5","readings":["3.5",null],"samples":[4.5,null],"count":3}""", json); - Assert.NotNull(result); - Assert.Equal(model.Measurement, result.Measurement); - Assert.Equal(model.Score, result.Score); - Assert.Equal(model.Readings, result.Readings); - Assert.Equal(model.Samples, result.Samples); - JsonAssert.Equal("""["1.25",null,null]""", listJson); - Assert.Equal(new double?[] { 1.25, null }, list); - JsonAssert.Equal("""{"finite":"1.25","nonFinite":null}""", mapJson); - Assert.Equal(new StringNumberMap { ["finite"] = 1.25f, ["nothing"] = null }, restoredMap); + JsonAssert.Equal("""{"measurement":null,"score":null}""", json); } [Theory] @@ -166,55 +135,26 @@ public void PreservesOptionsNumberHandling(JsonNumberHandling handling, string e [InlineData("NaN", double.NaN)] [InlineData("Infinity", double.PositiveInfinity)] [InlineData("-Infinity", double.NegativeInfinity)] - public async Task PreservesExplicitNamedNumberReads(string namedValue, double expected) + public void PreservesOptionsNamedNumberReads(string namedValue, double expected) { - var json = "{\"measurement\":\"" + namedValue + "\",\"score\":\"" + namedValue + "\"}"; - - var model = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync(json); var options = new JsonSerializerOptions(JsonSerializerHelper.Options) { NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals }; - Assert.NotNull(model); - Assert.Equal(expected, model.Measurement); - Assert.Equal((float)expected, model.Score); Assert.Equal(expected, JsonSerializer.Deserialize("\"" + namedValue + "\"", options)); Assert.Equal((float)expected, JsonSerializer.Deserialize("\"" + namedValue + "\"", options)); - JsonAssert.Equal("""{"measurement":null,"score":null}""", await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model)); - } - - [Theory] - [InlineData(JsonNumberHandling.Strict)] - [InlineData(JsonNumberHandling.WriteAsString)] - public void MemberNumberHandlingOverridesCollectionType(JsonNumberHandling optionsHandling) - { - var model = new MemberOverrideModel - { - Values = [1.25, double.NaN, null], - Metrics = new StringNumberMap { ["finite"] = 2.5f, ["nonFinite"] = float.PositiveInfinity } - }; - var options = new JsonSerializerOptions(JsonSerializerHelper.Options) { NumberHandling = optionsHandling }; - - var json = JsonSerializer.Serialize(model, options); - var result = JsonSerializer.Deserialize("""{"values":[1.25,null],"metrics":{"finite":2.5}}""", options); - - JsonAssert.Equal("""{"values":[1.25,null,null],"metrics":{"finite":2.5,"nonFinite":null}}""", json); - Assert.NotNull(result); - Assert.Equal(new double?[] { 1.25, null }, result.Values); - Assert.Equal(new StringNumberMap { ["finite"] = 2.5f }, result.Metrics); - Assert.Throws(() => JsonSerializer.Deserialize("""{"values":["1.25"]}""", options)); - Assert.Throws(() => JsonSerializer.Deserialize("""{"metrics":{"finite":"2.5"}}""", options)); + JsonAssert.Equal("null", JsonSerializer.Serialize(expected, options)); + JsonAssert.Equal("null", JsonSerializer.Serialize((float)expected, options)); } [Fact] - public async Task PreservesPlatformNumberHandlingForObjectMembers() + public async Task WritesAttributedObjectMembersWithoutChangingFloatingPointFormatting() { foreach (var value in new object[] { - 1.25, 2.5f, new { Amount = 3.5, Count = 2 }, new object[] { 4.5, "NaN" }, - new { Values = new StringNumberList { 1.25 }, Metrics = new StringNumberMap { ["value"] = 2.5f } } + 1.25, 2.5f, new { Amount = 3.5, Count = 2 }, new object[] { 4.5, "NaN" } }) { var model = new ObjectNumberModel { Value = value }; - var expected = JsonSerializer.Serialize(model, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + var expected = JsonSerializer.Serialize(new { Value = value }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model); @@ -242,42 +182,6 @@ public sealed class StringNumberModel public float Score { get; set; } } -#pragma warning disable CA1819, CA2227, CA1002 // Test models exercise array and mutable collection serialization. - [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] - public sealed class NumberHandlingModel - { - public double? Measurement { get; set; } - public float? Score { get; set; } - public double?[] Readings { get; set; } = []; - [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] - public List Samples { get; set; } = []; - - [JsonNumberHandling(JsonNumberHandling.Strict)] - public double Count { get; set; } = 3; - } - - [JsonNumberHandling(JsonNumberHandling.Strict)] - public sealed class MemberOverrideModel - { - [JsonNumberHandling(JsonNumberHandling.Strict)] - public StringNumberList Values { get; set; } = []; - public StringNumberMap Metrics { get; set; } = []; - } -#pragma warning restore CA1819, CA2227, CA1002 - - [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] - public sealed class StringNumberList : List; - - [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] - public sealed class StringNumberMap : Dictionary; - - [JsonNumberHandling(JsonNumberHandling.AllowNamedFloatingPointLiterals)] - public sealed class NamedNumberModel - { - public double? Measurement { get; set; } - public float? Score { get; set; } - } - public sealed class ObjectNumberModel { [JsonNumberHandling(JsonNumberHandling.WriteAsString)] diff --git a/tests/UnitTests/Json/NonFiniteNumberCompatibilityTests.cs b/tests/UnitTests/Json/NonFiniteNumberCompatibilityTests.cs new file mode 100644 index 00000000..986c60ef --- /dev/null +++ b/tests/UnitTests/Json/NonFiniteNumberCompatibilityTests.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using PostHog.Json; +using PostHog.Library; + +namespace NonFiniteNumberCompatibilityTests; + +public class TheSerializeMethod +{ + [Fact] + public async Task FlattensExtensionDataOnNumberHandlingModels() + { + var model = new ExtensionDataModel + { + Extra = new Dictionary { ["value"] = 1.25, ["nonFinite"] = double.NaN } + }; + + var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model); + + JsonAssert.Equal("""{"value":1.25,"nonFinite":null}""", json); + } + + [Fact] + public async Task SkipsIgnoredMembersWithInvalidMetadata() + { + var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(new IgnoredMemberModel()); + + JsonAssert.Equal("""{"value":1.25}""", json); + } + + [Fact] + public async Task SerializesPopulationCapableCollections() + { + var model = new PopulatedCollectionModel(); + model.Values.Add(1.25); + model.Values.Add(double.NaN); + + var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model); + + JsonAssert.Equal("""{"values":[1.25,null]}""", json); + } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + public sealed class ExtensionDataModel + { + [JsonExtensionData] + public Dictionary Extra { get; init; } = []; + } + + public sealed class IgnoredMemberModel + { + public double Value { get; init; } = 1.25; + + [JsonIgnore] + public CollidingNamesModel? Ignored { get; init; } + } + + public sealed class CollidingNamesModel + { + [JsonPropertyName("name")] + public string? First { get; init; } + + [JsonPropertyName("name")] + public string? Second { get; init; } + } + +#pragma warning disable CA1002 // Exercise the built-in population-capable collection contract. + public sealed class PopulatedCollectionModel + { + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + public List Values { get; } = []; + } +#pragma warning restore CA1002 +} diff --git a/tests/UnitTests/PostHogClientTests.cs b/tests/UnitTests/PostHogClientTests.cs index 991904e9..c45d4bc9 100644 --- a/tests/UnitTests/PostHogClientTests.cs +++ b/tests/UnitTests/PostHogClientTests.cs @@ -486,7 +486,7 @@ public async Task SendsNonFinitePropertiesAsNullWithNeighboringEvent(object valu Assert.True(sent.GetProperty("enabled").GetBoolean()); Assert.Equal(JsonValueKind.Null, sent.GetProperty("nothing").ValueKind); Assert.Equal("Infinity", sent.GetProperty("literal").GetString()); - JsonAssert.Equal("""{"measurement":"1.25","score":"2.5"}""", sent.GetProperty("model").GetRawText()); + JsonAssert.Equal("""{"measurement":1.25,"score":2.5}""", sent.GetProperty("model").GetRawText()); Assert.Equal("neighbor", batch[1].GetProperty("event").GetString()); Assert.Equal(42, batch[1].GetProperty("properties").GetProperty("finite").GetInt32()); Assert.Same(value, properties["value"]); From 183dcff3e719c80838da1c2348287dff89d3a448 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 22 Oct 2026 16:42:29 +0200 Subject: [PATCH 6/6] docs: revert package README serialization notes --- src/PostHog/README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/PostHog/README.md b/src/PostHog/README.md index 6893a03c..7c181754 100644 --- a/src/PostHog/README.md +++ b/src/PostHog/README.md @@ -4,12 +4,6 @@ Please see the main [PostHog docs](https://posthog.com/docs). SDK usage examples and code snippets live in the official documentation so they stay up to date. -## Event property serialization - -Numeric `float` and `double` NaN and infinity values are sent as JSON `null`, including in nested objects and arrays. Finite values remain JSON numbers and literal strings are unchanged. - -The SDK's float/double converters do not apply `[JsonNumberHandling]` attributes on user models or their members, including collection elements. For example, `[JsonNumberHandling(WriteAsString)]` does not turn a finite double into a JSON string. Supply a string property value or an explicit `[JsonConverter]` when custom formatting is required. - ## Documentation - [.NET library docs](https://posthog.com/docs/libraries/dotnet)