diff --git a/.changeset/plain-forks-design.md b/.changeset/plain-forks-design.md new file mode 100644 index 00000000..a00dd947 --- /dev/null +++ b/.changeset/plain-forks-design.md @@ -0,0 +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 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..36a6afc7 --- /dev/null +++ b/src/PostHog/Json/NonFiniteDoubleJsonConverter.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +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, BuiltInOptions[(int)options.NumberHandling]); + + public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + { + writer.WriteNullValue(); + } + else if ((options.NumberHandling & JsonNumberHandling.WriteAsString) != 0) + { + JsonSerializer.Serialize(writer, value, BuiltInOptions[(int)options.NumberHandling]); + } + else + { + writer.WriteNumberValue(value); + } + } +} diff --git a/src/PostHog/Json/NonFiniteSingleJsonConverter.cs b/src/PostHog/Json/NonFiniteSingleJsonConverter.cs new file mode 100644 index 00000000..dd9ea57b --- /dev/null +++ b/src/PostHog/Json/NonFiniteSingleJsonConverter.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +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, BuiltInOptions[(int)options.NumberHandling]); + + public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOptions options) + { + if (float.IsNaN(value) || float.IsInfinity(value)) + { + writer.WriteNullValue(); + } + else if ((options.NumberHandling & JsonNumberHandling.WriteAsString) != 0) + { + JsonSerializer.Serialize(writer, value, BuiltInOptions[(int)options.NumberHandling]); + } + else + { + writer.WriteNumberValue(value); + } + } +} diff --git a/tests/UnitTests/Json/JsonSerializerHelperTests.cs b/tests/UnitTests/Json/JsonSerializerHelperTests.cs index 68f7c7a5..ea2b1af1 100644 --- a/tests/UnitTests/Json/JsonSerializerHelperTests.cs +++ b/tests/UnitTests/Json/JsonSerializerHelperTests.cs @@ -2,11 +2,205 @@ using PostHog.Json; using PostHog.Library; using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; 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 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); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public async Task WritesAttributedNonFiniteNumbersAsNull(double value) + { + var model = new StringNumberModel { Measurement = value, Score = (float)value }; + + var json = await JsonSerializerHelper.SerializeToCamelCaseJsonStringAsync(model); + + JsonAssert.Equal("""{"measurement":null,"score":null}""", json); + } + + [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 void PreservesOptionsNamedNumberReads(string namedValue, double expected) + { + var options = new JsonSerializerOptions(JsonSerializerHelper.Options) { NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals }; + + Assert.Equal(expected, JsonSerializer.Deserialize("\"" + namedValue + "\"", options)); + Assert.Equal((float)expected, JsonSerializer.Deserialize("\"" + namedValue + "\"", options)); + JsonAssert.Equal("null", JsonSerializer.Serialize(expected, options)); + JsonAssert.Equal("null", JsonSerializer.Serialize((float)expected, options)); + } + + [Fact] + 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" } + }) + { + var model = new ObjectNumberModel { Value = value }; + var expected = JsonSerializer.Serialize(new { Value = value }, 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; } + } + + 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/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/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 2c4bba45..c45d4bc9 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,83 @@ 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 = 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; + }, 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", + ["model"] = new JsonSerializerHelperTests.TheSerializeToCamelCaseJsonMethod.StringNumberModel { Measurement = 1.25, Score = 2.5f } + }; + + 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()); + 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"]); + 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() {