diff --git a/Source/PostHog/Private/PostHogFlagCache.cpp b/Source/PostHog/Private/PostHogFlagCache.cpp index c6aae94..798b331 100644 --- a/Source/PostHog/Private/PostHogFlagCache.cpp +++ b/Source/PostHog/Private/PostHogFlagCache.cpp @@ -9,6 +9,8 @@ bool ParseRecords(const FJsonObject& Records, TMapType != EJson::Object) { return false; } const auto Record = Pair.Value->AsObject(); + bool bFailed = false; + if (Record->TryGetBoolField(TEXT("failed"), bFailed) && bFailed) { continue; } const auto Value = Record->TryGetField(TEXT("value")); if (!Value || (Value->Type != EJson::Boolean && Value->Type != EJson::String)) { return false; } FPostHogFeatureFlagResult Flag; diff --git a/Source/PostHog/Private/Tests/PostHogFailedFlagCacheTests.cpp b/Source/PostHog/Private/Tests/PostHogFailedFlagCacheTests.cpp new file mode 100644 index 0000000..4b532cd --- /dev/null +++ b/Source/PostHog/Private/Tests/PostHogFailedFlagCacheTests.cpp @@ -0,0 +1,75 @@ +#if WITH_DEV_AUTOMATION_TESTS +#include "PostHogFlagCache.h" +#include "PostHogJson.h" +#include "Misc/AutomationTest.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FPostHogFailedFlagCacheTest, "PostHog.FailedFlagCache", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) +bool FPostHogFailedFlagCacheTest::RunTest(const FString& Parameters) +{ + // These are normalized export records, not the upstream /flags wire format. + for (bool bPartial : {false, true}) + { + FPostHogFlagCache Cache; + auto Initial = PostHogJson::Decode(TEXT(R"({"flags":{ + "failed-bool":{"value":true,"payload":{"copy":"local"}}, + "failed-disabled":{"value":false,"payload":{"copy":"local"}}, + "failed-variant":{"value":"blue","payload":{"copy":"local"}} + },"request_id":"initial","errors_while_computing":false,"quota_limited":false})")); + TestEqual(TEXT("initial load"), Cache.Apply(Initial.ToSharedRef()), EPostHogFlagReloadStatus::Success); + auto Response = PostHogJson::Decode(TEXT(R"({"flags":{ + "failed-bool":{"value":false,"failed":true,"payload":{"copy":"placeholder"}}, + "failed-disabled":{"value":false,"failed":true,"payload":{"copy":"placeholder"}}, + "failed-variant":{"value":"red","failed":true,"payload":{"copy":"placeholder"}}, + "failed-fresh":{"value":false,"failed":true,"payload":true}, + "disabled":{"value":false},"explicit-healthy":{"value":false,"failed":false}, + "healthy":{"value":"green","payload":{"copy":"healthy"}} + },"request_id":"refresh","errors_while_computing":false,"quota_limited":false})")); + Response->SetBoolField(TEXT("errors_while_computing"), bPartial); + TestEqual(TEXT("refresh status"), Cache.Apply(Response.ToSharedRef()), + bPartial ? EPostHogFlagReloadStatus::Partial : EPostHogFlagReloadStatus::Success); + TestFalse(TEXT("fresh failure has no result"), Cache.Flags.Contains(TEXT("failed-fresh"))); + TestFalse(TEXT("fresh failure omitted from bulk/enrichment"), Cache.Values().ToNative()->HasField(TEXT("failed-fresh"))); + TestTrue(TEXT("healthy false without failure marker"), Cache.Flags.Contains(TEXT("disabled")) && + !Cache.Flags[TEXT("disabled")].Value.ToNative()->AsBool()); + TestTrue(TEXT("explicit healthy false"), Cache.Flags.Contains(TEXT("explicit-healthy")) && + !Cache.Flags[TEXT("explicit-healthy")].Value.ToNative()->AsBool()); + TestEqual(TEXT("healthy sibling payload"), Cache.Flags[TEXT("healthy")].Payload.ToJson(), FString(TEXT("{\"copy\":\"healthy\"}"))); + if (bPartial) + { + TestTrue(TEXT("partial refresh keeps successful boolean"), Cache.Flags[TEXT("failed-bool")].Value.ToNative()->AsBool()); + TestTrue(TEXT("partial refresh keeps successful false"), Cache.Flags.Contains(TEXT("failed-disabled")) && + !Cache.Flags[TEXT("failed-disabled")].Value.ToNative()->AsBool()); + TestEqual(TEXT("partial refresh keeps successful variant"), Cache.Flags[TEXT("failed-variant")].Value.ToJson(), FString(TEXT("\"blue\""))); + for (const FString& Key : {FString(TEXT("failed-bool")), FString(TEXT("failed-disabled")), FString(TEXT("failed-variant"))}) + { + const auto& Flag = Cache.Flags[Key]; + TestEqual(TEXT("partial refresh keeps paired payload"), Flag.Payload.ToJson(), FString(TEXT("{\"copy\":\"local\"}"))); + TestEqual(TEXT("retained value keeps source metadata"), Flag.Metadata.ToNative()->GetStringField(TEXT("request_id")), FString(TEXT("initial"))); + TestFalse(TEXT("unused failure does not taint retained exposure"), FPostHogFlagCache::Exposure(Key, Flag).ToNative()->HasField(TEXT("$feature_flag_error"))); + } + } + else + { + TestFalse(TEXT("complete refresh removes failed stale boolean"), Cache.Flags.Contains(TEXT("failed-bool"))); + TestFalse(TEXT("complete refresh removes failed stale false"), Cache.Flags.Contains(TEXT("failed-disabled"))); + TestFalse(TEXT("complete refresh removes failed stale variant"), Cache.Flags.Contains(TEXT("failed-variant"))); + } + FPostHogFlagCache Restored; + TestTrue(TEXT("filtered cache restores"), Restored.Restore(Cache.Serialize())); + TestFalse(TEXT("failure payload is not persisted"), Restored.Serialize().ToNative()->HasField(TEXT("failed-fresh"))); + } + + FPostHogFlagCache Cache; + auto AllFailed = PostHogJson::Decode(TEXT(R"({"flags":{ + "placeholder":{"failed":true},"variant":{"value":"red","failed":true,"payload":"placeholder"} + },"errors_while_computing":false,"quota_limited":false})")); + TestEqual(TEXT("all failed is a complete empty success"), Cache.Apply(AllFailed.ToSharedRef()), EPostHogFlagReloadStatus::Success); + TestTrue(TEXT("all failed has no values or payloads"), Cache.bLoaded && Cache.Flags.IsEmpty() && Cache.Serialize().IsEmpty()); + FPostHogProperties Saved; + TestTrue(TEXT("saved fixture parses"), FPostHogProperties::TryParse(TEXT(R"({"failed":{"value":false,"failed":true,"payload":"placeholder"},"healthy":{"value":false}})"), Saved)); + TestTrue(TEXT("restore filters failed records"), Cache.Restore(Saved)); + TestTrue(TEXT("restore keeps healthy false only"), Cache.Flags.Num() == 1 && Cache.Flags.Contains(TEXT("healthy"))); + return true; +} +#endif diff --git a/docs/implementation-status.md b/docs/implementation-status.md index eefb261..894772a 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -80,6 +80,10 @@ The native shutdown deadline starts before waiting for Unreal state writes, whil ## Feature-flag limitations +- Spec #211: the C++ cache defensively omits normalized records with explicit `failed: true`, including their payloads, before reading a value. Complete nonerrored refreshes replace state after filtering; response-wide errored refreshes merge healthy records and retain prior successful values and metadata. Restore uses the same filter. Missing direct reads already return no result, honor existing caller defaults, and emit no exposure event; this patch does not add missing-read tracking. +- This defensive filter does **not** establish end-to-end failed-record support. `phu_flags` serializes `flags.export()` from the external embedding checkout. The inspected `posthog-rs` embedding branch (`b1973b06c50911d7cb2a2db8ef92a8d88a5e3e4c`) decodes no `failed` field in `FlagDetail`, and neither `EvaluatedFlagRecord` nor `ExportedFeatureFlag` retains it. Failed wire records can therefore still arrive as indistinguishable successful bool/string values. Mainline does not provide the bridge's required embedding/export APIs. Before claiming support, land failed-aware decoding/filtering in that exporter and verify detailed responses (including false/absent response-wide error bits) through the actual ABI and public getters. No new dependency version or ABI is selected here. +- The focused `PostHog.FailedFlagCache` regression exercises the C++ cache with normalized fixtures; it is not an exporter or wire-delivery conformance test. Failed-read error diagnostics cannot be reconstructed after the exporter discards failure identity. Any future emitted failed-read events must preserve that identity and use the existing missing-response convention without substituting caller defaults. + - Rust currently returns malformed serialized payloads as raw strings, indistinguishable from valid decoded string payloads. Strict malformed-payload rejection is deferred. Fix the upstream export's parse-validity information before stable release; previously persisted ambiguous caches may need invalidation. Unreal does not reparse exported strings. - The mock server's quota-only array response is rejected by the current Rust parser, so it surfaces as a generic load failure with same-context cache retention. Recognized normalized quota responses preserve the cache and return `QuotaLimited`. - Targeting overrides are explicit; analytics person/group properties are not automatically mirrored. The mock records identity/group membership but not targeting property bodies; targeting serialization is checked through the persisted request context and ABI/source mapping.