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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Source/PostHog/Private/PostHogClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,9 @@ struct FPostHogClient::FImpl : public TSharedFromThis<FImpl, ESPMode::ThreadSafe
Envelope->SetStringField(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<FJsonValueObject>(Properties));
Envelope->SetObjectField(TEXT("properties"), Properties);
const FString Json = PostHogJson::Encode(Envelope);
if (!PostHogJson::IsBounded(Json)) { return EPostHogCaptureResult::Invalid; }
const FTCHARToUTF8 Utf8(*Json);
Expand Down
2 changes: 1 addition & 1 deletion Source/PostHog/Private/PostHogProperties.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ FPostHogValue FPostHogValue::Boolean(bool Value)
FPostHogValue FPostHogValue::Number(double Value)
{
FPostHogValue Result;
Result.Json = FMath::IsFinite(Value) ? PostHogJson::EncodeValue(MakeShared<FJsonValueNumber>(Value)) : FString();
Result.Json = FMath::IsFinite(Value) ? PostHogJson::EncodeValue(MakeShared<FJsonValueNumber>(Value)) : TEXT("null");
Comment thread
marandaneto marked this conversation as resolved.
Resolved
return Result;
}
FPostHogValue FPostHogValue::Object(const FPostHogProperties& Value)
Expand Down
83 changes: 81 additions & 2 deletions Source/PostHog/Private/Tests/PostHogClientTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<double>::quiet_NaN()));
const double NonFiniteNumbers[] = { std::numeric_limits<double>::quiet_NaN(),
std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::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<EJson::Null>(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<EJson::Null>(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<FPostHogValue> 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;
Expand Down Expand Up @@ -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<double>::quiet_NaN(),
std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::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"));
Expand Down Expand Up @@ -119,9 +176,24 @@ bool FPostHogClientTest::RunTest(const FString& Parameters)

Config.ClientName = TEXT("hook");
auto Weak = MakeShared<TWeakPtr<FPostHogClient, ESPMode::ThreadSafe>, ESPMode::ThreadSafe>();
Config.BeforeSend = [Weak](FPostHogEvent& Event)
auto HookNumber = MakeShared<double, ESPMode::ThreadSafe>(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(); } }
Expand All @@ -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();

Expand Down
1 change: 1 addition & 0 deletions Source/PostHog/Public/PostHogProperties.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<FPostHogValue>& Values);
Expand Down
34 changes: 33 additions & 1 deletion Tests/native_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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"])
Expand Down
Loading