Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/quiet-otters-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"PostHog": patch
---

Omit null-valued event object properties recursively while preserving null array positions and supported property values.
4 changes: 4 additions & 0 deletions src/PostHog/Api/PostHogApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ public async Task<ApiResult> SendEventAsync(
CancellationToken cancellationToken)
{
PrepareAndMutatePayload(payload);
payload["properties"] = CapturedEventJsonConverter.NormalizeProperties(
payload["properties"],
payload.GetValueOrDefault("event") as string,
JsonSerializerHelper.Options)!;

var endpointUrl = new Uri(HostUrl, "capture");

Expand Down
80 changes: 80 additions & 0 deletions src/PostHog/Json/CapturedEventJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using PostHog.Api;

namespace PostHog.Json;

// Only event properties are normalized; generic JSON for flag requests and caches is unchanged.
internal sealed class CapturedEventJsonConverter : JsonConverter<CapturedEvent>
{
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1869:Cache and reuse JsonSerializerOptions instances", Justification = "Reading must preserve the caller's options while bypassing this write-boundary converter.")]
public override CapturedEvent? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var readOptions = new JsonSerializerOptions(options);
readOptions.Converters.Remove(this);
return JsonSerializer.Deserialize<CapturedEvent>(ref reader, readOptions);
}

public override void Write(Utf8JsonWriter writer, CapturedEvent value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteString("uuid", value.Uuid);
writer.WriteString("event", value.EventName);
writer.WriteString("distinct_id", value.DistinctId);
writer.WritePropertyName("properties");
JsonSerializer.Serialize(writer, NormalizeProperties(value.Properties, value.EventName, options), options);
writer.WritePropertyName("timestamp");
JsonSerializer.Serialize(writer, value.Timestamp, options);
writer.WriteEndObject();
}

internal static JsonElement NormalizeProperties(object properties, string? eventName, JsonSerializerOptions options)
{
// Serialize first so POCOs, DOM values and custom converters follow their existing JSON contracts.
// Enumerating JSON tokens preserves ordered, case-distinct and duplicate property names.
var element = JsonSerializer.SerializeToElement(properties, options);
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Encoder = options.Encoder, MaxDepth = options.MaxDepth }))
{
WriteWithoutNullMembers(writer, element, preserveExceptionMetadata: eventName == "$exception");
}
stream.Position = 0;
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions { MaxDepth = options.MaxDepth });
return document.RootElement.Clone();
}

static void WriteWithoutNullMembers(Utf8JsonWriter writer, JsonElement element, bool preserveExceptionMetadata = false)
{
if (element.ValueKind == JsonValueKind.Object)
{
writer.WriteStartObject();
foreach (var property in element.EnumerateObject())
{
// Exception stack frames intentionally have nullable typed fields.
if (preserveExceptionMetadata && property.NameEquals("$exception_list"))
{
property.WriteTo(writer);
}
else if (property.Value.ValueKind != JsonValueKind.Null)
{
writer.WritePropertyName(property.Name);
WriteWithoutNullMembers(writer, property.Value);
}
}
writer.WriteEndObject();
}
else if (element.ValueKind == JsonValueKind.Array)
{
writer.WriteStartArray();
foreach (var item in element.EnumerateArray())
{
WriteWithoutNullMembers(writer, item);
}
writer.WriteEndArray();
}
else
{
element.WriteTo(writer);
}
}
}
1 change: 1 addition & 0 deletions src/PostHog/Json/JsonSerializerHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal static class JsonSerializerHelper
PropertyNameCaseInsensitive = true,
Converters =
{
new CapturedEventJsonConverter(),
new ReadOnlyCollectionJsonConverterFactory(),
new ReadOnlyDictionaryJsonConverterFactory()
}
Expand Down
75 changes: 75 additions & 0 deletions tests/PostHog.AI.Tests/NullPropertySerializationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;

namespace PostHog.AI.Tests;

public class NullPropertySerializationTests
{
[Fact]
public async Task HandlerContextAndHookPropertiesAreCleanedOnActualCoreWire()
{
using var transport = new OfflineTransport();
using var posthog = new PostHogClient(Options.Create(new PostHogOptions
{
ProjectToken = "test-token",
HostUrl = new Uri("http://127.0.0.1:1"),
EnableCompression = false,
FlushAt = 100,
FlushInterval = TimeSpan.FromHours(1),
BeforeSend = evt =>
{
evt.Properties["hookNull"] = null!;
evt.Properties["hookItems"] = new object?[] { null, new { Drop = (object?)null } };
return evt;
}
}), httpClientFactory: transport);
using var handler = new PostHogOpenAIHandler(posthog, NullLogger<PostHogOpenAIHandler>.Instance)
{
InnerHandler = transport
};
using var provider = new HttpClient(handler);
var custom = new Dictionary<string, object>
{
["test"] = null!,
["items"] = new object?[] { "1", null, 2, new Dictionary<string, object> { ["drop"] = null! }, new object?[] { null } },
["nested"] = new { Drop = (object?)null, Keep = false }
};
using var scope = PostHogAIContext.BeginScope(distinctId: "user", properties: custom);
using var request = new StringContent("{\"model\":\"test-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}");
using var response = await provider.PostAsync(new Uri("http://127.0.0.1:1/v1/chat/completions"), request);
Assert.True(response.IsSuccessStatusCode);
await posthog.FlushAsync();
using var json = JsonDocument.Parse(Assert.Single(transport.Events));
var evt = json.RootElement.GetProperty("batch")[0];
Assert.Equal("$ai_generation", evt.GetProperty("event").GetString());
var properties = evt.GetProperty("properties");
Assert.False(properties.TryGetProperty("test", out _));
Assert.False(properties.TryGetProperty("hookNull", out _));
Assert.Equal("[null,{}]", properties.GetProperty("hookItems").GetRawText());
Assert.Equal("[\"1\",null,2,{},[null]]", properties.GetProperty("items").GetRawText());
Assert.Equal("{\"keep\":false}", properties.GetProperty("nested").GetRawText());
Assert.Null(custom["test"]);
}

sealed class OfflineTransport : HttpMessageHandler, IHttpClientFactory
{
public List<string> Events { get; } = new();
public HttpClient CreateClient(string name) => new(this, disposeHandler: false);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Assert.True(request.RequestUri!.IsLoopback);
if (request.RequestUri.AbsolutePath == "/batch")
{
Events.Add(await request.Content!.ReadAsStringAsync(cancellationToken));
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"status\":1}") };
}
Assert.Equal("/v1/chat/completions", request.RequestUri.AbsolutePath);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"id\":\"test\",\"model\":\"test-model\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"hello\"}}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}")
};
}
}
}
Loading
Loading