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 Source/PostHog/Private/PostHogFlagCache.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ bool ParseRecords(const FJsonObject& Records, TMap<FString, FPostHogFeatureFlagR
{
if (Pair.Value->Type != 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;
Expand Down
75 changes: 75 additions & 0 deletions Source/PostHog/Private/Tests/PostHogFailedFlagCacheTests.cpp
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions docs/implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading