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/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/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..e051da7 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")); @@ -119,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(); } } @@ -134,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/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..2324b33 100644 --- a/Tests/native_integration.py +++ b/Tests/native_integration.py @@ -275,10 +275,22 @@ 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") + 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"] @@ -302,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"])