Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/plain-forks-design.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/PostHog/Json/JsonSerializerHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ internal static class JsonSerializerHelper
PropertyNameCaseInsensitive = true,
Converters =
{
new NonFiniteDoubleJsonConverter(),
new NonFiniteSingleJsonConverter(),
Comment thread
marandaneto marked this conversation as resolved.
new ReadOnlyCollectionJsonConverterFactory(),
new ReadOnlyDictionaryJsonConverterFactory()
}
Expand Down
30 changes: 30 additions & 0 deletions src/PostHog/Json/NonFiniteDoubleJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace PostHog.Json;

internal sealed class NonFiniteDoubleJsonConverter : JsonConverter<double>
{
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<double>(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);
}
}
}
30 changes: 30 additions & 0 deletions src/PostHog/Json/NonFiniteSingleJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace PostHog.Json;

internal sealed class NonFiniteSingleJsonConverter : JsonConverter<float>
{
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<float>(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);
}
}
}
194 changes: 194 additions & 0 deletions tests/UnitTests/Json/JsonSerializerHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<double, double> { [1.25] = 2.5 };
var singles = new Dictionary<float, float> { [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<Dictionary<double, double>>(doubleJson));
Assert.Equal(singles, await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync<Dictionary<float, float>>(singleJson));
}

[Fact]
public async Task StillDeserializesFiniteNumbers()
{
var doubles = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync<double[]>("[1.25,-2.5,0]");
var singles = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync<float[]>("[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<JsonException>(() => JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync<double>(json));
await Assert.ThrowsAsync<JsonException>(() => JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync<float>(json));
}

[Fact]
public async Task StillRejectsUnsupportedValues()
{
var obj = new { Callback = (Action)(() => { }) };

await Assert.ThrowsAsync<NotSupportedException>(() => 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<double[]>(input, options));
Assert.Equal(new[] { 1.25f, 2.5f }, JsonSerializer.Deserialize<float[]>(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<double>("\"" + namedValue + "\"", options));
Assert.Equal((float)expected, JsonSerializer.Deserialize<float>("\"" + 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<CustomNumberModel>(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<double>
{
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()
{
Expand Down
75 changes: 75 additions & 0 deletions tests/UnitTests/Json/NonFiniteNumberCompatibilityTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, object> { ["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<string, object> 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<double> Values { get; } = [];
}
#pragma warning restore CA1002
}
4 changes: 4 additions & 0 deletions tests/UnitTests/Library/HttpClientExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,10 @@ await Assert.ThrowsAsync<ApiException>(() =>
}
}

[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");
Expand Down
Loading
Loading