From 1d406fe2aead24ca62b909d4c95d186bcd28b1b1 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 22 Oct 2026 16:24:19 +0200 Subject: [PATCH 1/3] fix: accept non-finite typed event numbers as null --- README.md | 2 + Source/PostHog/Private/PostHogProperties.cpp | 2 +- .../Private/Tests/PostHogClientTests.cpp | 59 ++++++++++++++++++- Source/PostHog/Public/PostHogProperties.h | 1 + Tests/native_integration.py | 12 ++++ 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d5f99f2..59f3f1d 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,8 @@ if (Initialization.IsReady()) The client supports identity, registered/person/group properties, consent, reset, sessions, and a C++ `BeforeSend` hook. Properties are value copies with typed JSON values. The subsystem handles lifecycle binding automatically. For a directly owned client, sample context yourself, call `StartApplicationLifecycle` once ready, and forward `OnBackground`/`OnForeground` from your owner. +`FPostHogValue::Number` converts numeric NaN and positive/negative infinity to JSON null, and `SetNumber` accepts these assignments. Arrays retain null slots. Capture omits null-valued object members as usual, including those inside arrays, without changing caller-owned properties. Literal strings such as `"NaN"` remain strings. + `FlushAsync`, `OptOutAsync`, `OptInAsync`, `ResetAsync`, and `ShutdownAsync` return completion futures. Completion can run off-thread: marshal UObject work to the game thread. Capture acceptance is not a delivery receipt. Call `ShutdownAsync` when the owner stops; module teardown also closes clients and joins their workers. Live Coding and dynamic module reloading are not supported. ## Feature flags diff --git a/Source/PostHog/Private/PostHogProperties.cpp b/Source/PostHog/Private/PostHogProperties.cpp index a679137..0f9fbd7 100644 --- a/Source/PostHog/Private/PostHogProperties.cpp +++ b/Source/PostHog/Private/PostHogProperties.cpp @@ -96,7 +96,7 @@ FPostHogValue FPostHogValue::Boolean(bool Value) FPostHogValue FPostHogValue::Number(double Value) { FPostHogValue Result; - Result.Json = FMath::IsFinite(Value) ? PostHogJson::EncodeValue(MakeShared(Value)) : FString(); + Result.Json = FMath::IsFinite(Value) ? PostHogJson::EncodeValue(MakeShared(Value)) : TEXT("null"); return Result; } FPostHogValue FPostHogValue::Object(const FPostHogProperties& Value) diff --git a/Source/PostHog/Private/Tests/PostHogClientTests.cpp b/Source/PostHog/Private/Tests/PostHogClientTests.cpp index 3ab527c..3a6c703 100644 --- a/Source/PostHog/Private/Tests/PostHogClientTests.cpp +++ b/Source/PostHog/Private/Tests/PostHogClientTests.cpp @@ -31,7 +31,44 @@ bool FPostHogPropertiesTest::RunTest(const FString& Parameters) TestFalse(TEXT("Invalid JSON"), FPostHogValue::TryParse(TEXT("{bad"), Value)); TestFalse(TEXT("Multiple roots"), FPostHogValue::TryParse(TEXT("1,2"), Value)); TestFalse(TEXT("Non-object properties"), FPostHogProperties::TryParse(TEXT("[]"), Copy)); - TestFalse(TEXT("NaN"), Properties.SetNumber(TEXT("bad"), std::numeric_limits::quiet_NaN())); + const double NonFiniteNumbers[] = { std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), -std::numeric_limits::infinity() }; + for (double Number : NonFiniteNumbers) + { + const FPostHogValue NonFinite = FPostHogValue::Number(Number); + TestTrue(TEXT("Non-finite number is valid"), NonFinite.IsValid()); + TestEqual(TEXT("Non-finite number is JSON null"), NonFinite.ToJson(), FString(TEXT("null"))); + TestTrue(TEXT("Replace finite number with non-finite"), Properties.SetNumber(TEXT("number"), Number)); + TestTrue(TEXT("Non-finite setter stores null"), Properties.ToNative()->HasTypedField(TEXT("number"))); + TestTrue(TEXT("Finite setter still works"), Properties.SetNumber(TEXT("number"), 42.5)); + TestEqual(TEXT("Finite number unchanged"), Properties.ToNative()->GetNumberField(TEXT("number")), 42.5); + + FPostHogProperties Nested; + TestTrue(TEXT("Nested non-finite setter"), Nested.SetNumber(TEXT("bad"), Number)); + TestTrue(TEXT("Nested finite neighbor"), Nested.SetNumber(TEXT("kept"), 7)); + const FString NestedJson = Nested.ToJson(); + TestTrue(TEXT("Nested object"), Properties.Set(TEXT("nested"), FPostHogValue::Object(Nested))); + TestTrue(TEXT("Nested non-finite is null"), Properties.ToNative()->GetObjectField(TEXT("nested"))->HasTypedField(TEXT("bad"))); + TestEqual(TEXT("Nested neighbor survives"), Properties.ToNative()->GetObjectField(TEXT("nested"))->GetNumberField(TEXT("kept")), 7.0); + + FPostHogProperties ArrayObject; + TestTrue(TEXT("Array object non-finite setter"), ArrayObject.SetNumber(TEXT("bad"), Number)); + const TArray Items = { NonFinite, FPostHogValue::Number(7), FPostHogValue::Boolean(false), + FPostHogValue::String(TEXT("NaN")), FPostHogValue(), FPostHogValue::Object(ArrayObject), FPostHogValue::Number(8) }; + const FPostHogValue Array = FPostHogValue::Array(Items); + TestTrue(TEXT("Array with non-finite is valid"), Array.IsValid()); + TestEqual(TEXT("Array positions and neighbors survive"), Array.ToJson(), FString(TEXT("[null,7,false,\"NaN\",null,{\"bad\":null},8]"))); + TestTrue(TEXT("Array assignment"), Properties.Set(TEXT("items"), Array)); + FPostHogProperties RoundTrip; + TestTrue(TEXT("Properties remain JSON"), FPostHogProperties::TryParse(Properties.ToJson(), RoundTrip)); + TestEqual(TEXT("Caller array unchanged"), Items[0].ToJson(), FString(TEXT("null"))); + TestEqual(TEXT("Caller nested object unchanged"), Nested.ToJson(), NestedJson); + } + FPostHogValue TooDeep; + for (int32 Index = 0; Index < 65; ++Index) { TooDeep = FPostHogValue::Array({ TooDeep }); } + TestFalse(TEXT("Over-depth array remains invalid"), TooDeep.IsValid()); + TestFalse(TEXT("Invalid value assignment still rejected"), Properties.Set(TEXT("number"), TooDeep)); + TestEqual(TEXT("Rejected assignment preserves finite value"), Properties.ToNative()->GetNumberField(TEXT("number")), 42.5); TestFalse(TEXT("Depth bound"), FPostHogValue::TryParse(FString::ChrN(65, TEXT('[')) + TEXT("0") + FString::ChrN(65, TEXT(']')), Value)); TestFalse(TEXT("Size bound"), Properties.SetString(TEXT("big"), FString::ChrN(1024 * 1024, TEXT('x')))); return true; @@ -83,6 +120,26 @@ bool FPostHogClientTest::RunTest(const FString& Parameters) FPostHogProperties Properties; FPostHogProperties::TryParse(TEXT("{\"precedence\":\"caller\",\"$is_identified\":true,\"$process_person_profile\":true,\"null\":null,\"array\":[null,{\"gone\":null,\"kept\":1}],\"string\":\"00123\"}"), Properties); Check(Client->Capture(TEXT("stateful_anonymous"), Properties) == EPostHogCaptureResult::Accepted, TEXT("capture anonymous")); + const double NonFiniteNumbers[] = { std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), -std::numeric_limits::infinity() }; + for (int32 Index = 0; Index < 3; ++Index) + { + FPostHogProperties Numeric, Nested; + Check(Numeric.SetNumber(TEXT("bad"), NonFiniteNumbers[Index]), TEXT("non-finite direct setter accepted")); + Check(Nested.SetNumber(TEXT("bad"), NonFiniteNumbers[Index]), TEXT("non-finite nested setter accepted")); + Nested.SetNumber(TEXT("kept"), 7); + Numeric.Set(TEXT("nested"), FPostHogValue::Object(Nested)); + Check(Numeric.Set(TEXT("items"), FPostHogValue::Array({ FPostHogValue::Number(NonFiniteNumbers[Index]), + FPostHogValue::Number(8), FPostHogValue::Boolean(false), FPostHogValue::String(TEXT("NaN")), + FPostHogValue(), FPostHogValue::Object(Nested) })), TEXT("non-finite array setter accepted")); + Numeric.SetNumber(TEXT("finite"), 42.5); + Numeric.SetString(TEXT("literal"), TEXT("Infinity")); + const FString BeforeCapture = Numeric.ToJson(); + Check(Client->Capture(FString::Printf(TEXT("stateful_nonfinite_%d"), Index), Numeric) == EPostHogCaptureResult::Accepted, TEXT("capture non-finite properties")); + Check(Numeric.ToJson() == BeforeCapture, TEXT("capture preserves caller properties")); + } + FPostHogProperties Neighbor; Neighbor.SetNumber(TEXT("finite"), 123); + Check(Client->Capture(TEXT("stateful_numeric_neighbor"), Neighbor) == EPostHogCaptureResult::Accepted, TEXT("capture finite neighbor")); Check(Client->Identify(TEXT("user-one")), TEXT("identify anonymous")); Check(!Client->Identify(TEXT("user-one")), TEXT("duplicate identify suppressed")); Check(!Client->Identify(TEXT("user-two")), TEXT("account switch requires reset")); diff --git a/Source/PostHog/Public/PostHogProperties.h b/Source/PostHog/Public/PostHogProperties.h index 2b24ef1..c0d5ad8 100644 --- a/Source/PostHog/Public/PostHogProperties.h +++ b/Source/PostHog/Public/PostHogProperties.h @@ -15,6 +15,7 @@ struct POSTHOG_API FPostHogValue static FPostHogValue String(const FString& Value); static FPostHogValue Boolean(bool Value); + /** Non-finite numbers become JSON null. */ static FPostHogValue Number(double Value); static FPostHogValue Object(const FPostHogProperties& Value); static FPostHogValue Array(const TArray& Values); diff --git a/Tests/native_integration.py b/Tests/native_integration.py index 12e12f0..ca36b0c 100644 --- a/Tests/native_integration.py +++ b/Tests/native_integration.py @@ -279,6 +279,18 @@ def test_unreal_editor_native_integration(self): wire = self.http("/__captured/events") events = {event["event"]: event for event in wire} self.assertEqual(events["unreal_native_integration"]["properties"]["$lib"], "posthog-unreal") + for index in range(3): + name = f"stateful_nonfinite_{index}" + self.assertEqual(sum(event["event"] == name for event in wire), 1) + numeric = events[name]["properties"] + self.assertNotIn("bad", numeric) + self.assertEqual(numeric["nested"], {"kept": 7}) + self.assertEqual(numeric["items"], [None, 8, False, "NaN", None, {"kept": 7}]) + self.assertEqual(numeric["finite"], 42.5) + self.assertEqual(numeric["literal"], "Infinity") + json.dumps(numeric, allow_nan=False) + self.assertEqual(sum(event["event"] == "stateful_numeric_neighbor" for event in wire), 1) + self.assertEqual(events["stateful_numeric_neighbor"]["properties"]["finite"], 123) anonymous = events["stateful_anonymous"] identified = events["stateful_identified"] reset = events["stateful_reset"] From c0033824c67770e39b4263efa3b446f4a810b386 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 23 Oct 2026 09:51:04 +0200 Subject: [PATCH 2/3] fix: clean null object properties after before-send hooks --- Source/PostHog/Private/PostHogClient.cpp | 4 +++- .../Private/Tests/PostHogClientTests.cpp | 24 ++++++++++++++++++- Tests/native_integration.py | 20 ++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/Source/PostHog/Private/PostHogClient.cpp b/Source/PostHog/Private/PostHogClient.cpp index 2577715..e12b958 100644 --- a/Source/PostHog/Private/PostHogClient.cpp +++ b/Source/PostHog/Private/PostHogClient.cpp @@ -287,7 +287,9 @@ struct FPostHogClient::FImpl : public TSharedFromThisSetStringField(TEXT("distinct_id"), Event.DistinctId); Envelope->SetStringField(TEXT("uuid"), Event.Uuid); Envelope->SetStringField(TEXT("timestamp"), Event.Timestamp.ToIso8601()); - Envelope->SetObjectField(TEXT("properties"), Event.Properties.ToNative()); + auto Properties = Event.Properties.ToNative(); + PostHogJson::RemoveNullObjectProperties(MakeShared(Properties)); + Envelope->SetObjectField(TEXT("properties"), Properties); const FString Json = PostHogJson::Encode(Envelope); if (!PostHogJson::IsBounded(Json)) { return EPostHogCaptureResult::Invalid; } const FTCHARToUTF8 Utf8(*Json); diff --git a/Source/PostHog/Private/Tests/PostHogClientTests.cpp b/Source/PostHog/Private/Tests/PostHogClientTests.cpp index 3a6c703..e051da7 100644 --- a/Source/PostHog/Private/Tests/PostHogClientTests.cpp +++ b/Source/PostHog/Private/Tests/PostHogClientTests.cpp @@ -176,9 +176,24 @@ bool FPostHogClientTest::RunTest(const FString& Parameters) Config.ClientName = TEXT("hook"); auto Weak = MakeShared, ESPMode::ThreadSafe>(); - Config.BeforeSend = [Weak](FPostHogEvent& Event) + auto HookNumber = MakeShared(0.0); + Config.BeforeSend = [Weak, HookNumber](FPostHogEvent& Event) { Event.Properties.Remove(TEXT("secret")); Event.Properties.Remove(TEXT("$lib")); + if (Event.Event.StartsWith(TEXT("stateful_hook_numeric_"))) + { + Event.Properties.SetNumber(TEXT("computed"), *HookNumber); + Event.Properties.Set(TEXT("explicit_null"), FPostHogValue()); + FPostHogProperties Nested; + Nested.SetNumber(TEXT("computed"), *HookNumber); + Nested.SetNumber(TEXT("kept"), 7); + Event.Properties.Set(TEXT("nested"), FPostHogValue::Object(Nested)); + Event.Properties.Set(TEXT("items"), FPostHogValue::Array({ FPostHogValue::Number(*HookNumber), + FPostHogValue::Number(8), FPostHogValue::Boolean(false), FPostHogValue::String(TEXT("NaN")), + FPostHogValue(), FPostHogValue::Object(Nested) })); + Event.Properties.SetNumber(TEXT("finite"), 42.5); + Event.Properties.SetString(TEXT("literal"), TEXT("Infinity")); + } if (Event.Event == TEXT("hook_withdraw")) { if (auto Current = Weak->Pin()) { Current->OptOutAsync(); } } if (Event.Event == TEXT("hook_withdraw_resume")) { if (auto Current = Weak->Pin()) { Current->OptOutAsync().Get(); Current->OptInAsync().Get(); } } @@ -191,6 +206,13 @@ bool FPostHogClientTest::RunTest(const FString& Parameters) Check(Client->OptInAsync().Get(), TEXT("opt back in")); Check(Client->Capture(TEXT("hook_withdraw_resume"), Secret) == EPostHogCaptureResult::Disabled, TEXT("opt-in does not revive pre-opt-out snapshots")); Check(Client->Capture(TEXT("stateful_redacted"), Secret) == EPostHogCaptureResult::Accepted, TEXT("capture redacted")); + const FString BeforeHookCapture = Secret.ToJson(); + for (int32 Index = 0; Index < 4; ++Index) + { + *HookNumber = Index < 3 ? NonFiniteNumbers[Index] : 42.5; + Check(Client->Capture(FString::Printf(TEXT("stateful_hook_numeric_%d"), Index), Secret) == EPostHogCaptureResult::Accepted, TEXT("capture hook numeric properties")); + Check(Secret.ToJson() == BeforeHookCapture, TEXT("hook capture preserves caller properties")); + } Check(Client->FlushAsync().Get().Pending == 0, TEXT("flush redacted")); Check(Client->ShutdownAsync().Get(), TEXT("close hook client")); Client.Reset(); diff --git a/Tests/native_integration.py b/Tests/native_integration.py index ca36b0c..8bb676e 100644 --- a/Tests/native_integration.py +++ b/Tests/native_integration.py @@ -314,6 +314,26 @@ def test_unreal_editor_native_integration(self): self.assertEqual(sum(event["event"] == "$set" for event in wire), 1) self.assertNotIn("secret", events["stateful_redacted"]["properties"]) self.assertNotIn("$lib", events["stateful_redacted"]["properties"]) + for index in range(4): + name = f"stateful_hook_numeric_{index}" + self.assertEqual(sum(event["event"] == name for event in wire), 1) + numeric = events[name]["properties"] + nested = {"kept": 7} + computed = None + if index < 3: + self.assertNotIn("computed", numeric) + else: + computed = 42.5 + nested["computed"] = computed + self.assertEqual(numeric["computed"], computed) + self.assertNotIn("explicit_null", numeric) + self.assertEqual(numeric["nested"], nested) + self.assertEqual(numeric["items"], [computed, 8, False, "NaN", None, nested]) + self.assertEqual(numeric["finite"], 42.5) + self.assertEqual(numeric["literal"], "Infinity") + self.assertNotIn("secret", numeric) + self.assertNotIn("$lib", numeric) + json.dumps(numeric, allow_nan=False) for name in ("ignored", "closed", "hook_withdraw", "hook_withdraw_resume", "must_not_replay", "$reset"): self.assertNotIn(name, events) self.assertNotIn("$session_id", events["explicit_before"]["properties"]) From 67ad5aedfaa7cf9462cb010f27fae27751594e57 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 23 Oct 2026 10:27:00 +0200 Subject: [PATCH 3/3] test: count successful Unreal tests with warnings --- Tests/native_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/native_integration.py b/Tests/native_integration.py index 8bb676e..2324b33 100644 --- a/Tests/native_integration.py +++ b/Tests/native_integration.py @@ -275,7 +275,7 @@ def test_unreal_editor_native_integration(self): self.assertEqual(process.returncode, 0, str(evidence / "stdout.log")) result = json.loads((report / "index.json").read_text(encoding="utf-8-sig")) self.assertEqual(result["failed"], 0, result) - self.assertEqual(result["succeeded"], 8, result) + self.assertEqual(result["succeeded"] + result["succeededWithWarnings"], 8, result) wire = self.http("/__captured/events") events = {event["event"]: event for event in wire} self.assertEqual(events["unreal_native_integration"]["properties"]["$lib"], "posthog-unreal")