diff --git a/.changeset/minimal-flag-called-events.md b/.changeset/minimal-flag-called-events.md new file mode 100644 index 00000000..4ad8058d --- /dev/null +++ b/.changeset/minimal-flag-called-events.md @@ -0,0 +1,5 @@ +--- +"PostHog": minor +--- + +Send minimal `$feature_flag_called` events when the server enables it (`minimalFlagCalledEvents` in the `/flags` v2 response or `minimal_flag_called_events` in the local evaluation payload) and the evaluated flag is not linked to an experiment. Minimal events keep a strict allowlist of flag evaluation properties and strip everything else, including the `$feature/` enumeration and super properties. Experiment-linked flags and responses that do not carry the field continue to send the full event. diff --git a/src/PostHog/Api/FlagsApiResult.cs b/src/PostHog/Api/FlagsApiResult.cs index 6492f1d9..5101d970 100644 --- a/src/PostHog/Api/FlagsApiResult.cs +++ b/src/PostHog/Api/FlagsApiResult.cs @@ -44,6 +44,12 @@ internal record FlagsApiResult /// The timestamp when the feature flags were evaluated (milliseconds since Unix epoch). /// public long? EvaluatedAt { get; init; } + + /// + /// Whether the server gated this project into sending minimal $feature_flag_called events. + /// null when the server does not report the field (older deployments and legacy response formats). + /// + public bool? MinimalFlagCalledEvents { get; init; } } /// @@ -78,6 +84,12 @@ public record FlagsResult /// The list of feature flags that are limited by quota. /// public IReadOnlyList QuotaLimited { get; init; } = []; + + /// + /// Whether the server gated this project into sending minimal $feature_flag_called events. + /// false when the response does not carry the field. + /// + public bool MinimalFlagCalledEvents { get; init; } } /// @@ -129,7 +141,8 @@ public static FlagsResult ToFlagsResult(this FlagsApiResult? results) ErrorsWhileComputingFlags = results.ErrorsWhileComputingFlags, RequestId = results.RequestId, EvaluatedAt = results.EvaluatedAt, - QuotaLimited = results.QuotaLimited ?? [] + QuotaLimited = results.QuotaLimited ?? [], + MinimalFlagCalledEvents = results.MinimalFlagCalledEvents == true }; } } \ No newline at end of file diff --git a/src/PostHog/Api/LocalEvaluationApiResult.cs b/src/PostHog/Api/LocalEvaluationApiResult.cs index 843f12e8..891045f0 100644 --- a/src/PostHog/Api/LocalEvaluationApiResult.cs +++ b/src/PostHog/Api/LocalEvaluationApiResult.cs @@ -26,6 +26,13 @@ internal record LocalEvaluationApiResult /// public IReadOnlyDictionary? Cohorts { get; init; } + /// + /// Whether the server gated this project into sending minimal $feature_flag_called events. + /// null when the server does not report the field (older deployments). + /// + [JsonPropertyName("minimal_flag_called_events")] + public bool? MinimalFlagCalledEvents { get; init; } + /// /// Compares this instance to another for equality. /// @@ -48,14 +55,15 @@ public virtual bool Equals(LocalEvaluationApiResult? other) return Flags.ListsAreEqual(other.Flags) && GroupTypeMapping.DictionariesAreEqual(other.GroupTypeMapping) - && Cohorts.DictionariesAreEqual(other.Cohorts); + && Cohorts.DictionariesAreEqual(other.Cohorts) + && MinimalFlagCalledEvents == other.MinimalFlagCalledEvents; } /// /// Serves as the default hash function. /// /// A hash code for the current object. - public override int GetHashCode() => HashCode.Combine(Flags, GroupTypeMapping, Cohorts); + public override int GetHashCode() => HashCode.Combine(Flags, GroupTypeMapping, Cohorts, MinimalFlagCalledEvents); } /// diff --git a/src/PostHog/Features/EvaluatedFlagRecord.cs b/src/PostHog/Features/EvaluatedFlagRecord.cs index a70f193d..f33f9e93 100644 --- a/src/PostHog/Features/EvaluatedFlagRecord.cs +++ b/src/PostHog/Features/EvaluatedFlagRecord.cs @@ -35,4 +35,11 @@ internal sealed record EvaluatedFlagRecord /// on the emitted $feature_flag_called event. /// public bool LocallyEvaluated { get; init; } + + /// + /// Whether the server gated this project into sending minimal $feature_flag_called events, + /// as reported by the source that evaluated this record (the local definitions payload or the + /// /flags response). + /// + public bool MinimalFlagCalledEvents { get; init; } } diff --git a/src/PostHog/Features/LocalEvaluator.cs b/src/PostHog/Features/LocalEvaluator.cs index f7978394..2c12c014 100644 --- a/src/PostHog/Features/LocalEvaluator.cs +++ b/src/PostHog/Features/LocalEvaluator.cs @@ -72,6 +72,12 @@ public bool TryGetLocalFeatureFlag(string key, [NotNullWhen(returnValue: true)] /// public LocalEvaluationApiResult LocalEvaluationApiResult { get; } + /// + /// Whether the server gated this project into sending minimal $feature_flag_called events, + /// as reported by the local evaluation payload. false when the payload does not carry the field. + /// + public bool MinimalFlagCalledEvents => LocalEvaluationApiResult.MinimalFlagCalledEvents == true; + /// /// Constructs a with the specified flags. /// diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index 514a1ac9..9bf20930 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -25,6 +25,30 @@ public sealed class PostHogClient : IPostHogClient static readonly ApiResult NoOpApiResult = new(0); static readonly IReadOnlyDictionary EmptyFeatureFlags = new Dictionary(0); + // Strict allowlist for minimal $feature_flag_called events, shared across PostHog SDKs. + // Everything else — including super properties, context properties, the $feature/ + // enumeration, and system context — is stripped. + static readonly string[] MinimalFeatureFlagCalledEventProperties = + [ + "$feature_flag", + "$feature_flag_response", + "$feature_flag_has_experiment", + "$feature_flag_id", + "$feature_flag_version", + "$feature_flag_reason", + "$feature_flag_request_id", + "$feature_flag_evaluated_at", + "$feature_flag_error", + "locally_evaluated", + "$groups", + PostHogProperties.ProcessPersonProfile, + PostHogProperties.SessionId, + PostHogProperties.Lib, + PostHogProperties.LibVersion, + PostHogProperties.GeoIpDisable, + PostHogProperties.IsServer, + ]; + readonly TimeProvider _timeProvider; readonly IOptions _options; readonly ITaskScheduler _taskScheduler; @@ -278,7 +302,8 @@ bool CaptureCore( GroupCollection? groups, bool sendFeatureFlags, FeatureFlagEvaluations? flags, - DateTimeOffset? timestamp) + DateTimeOffset? timestamp, + bool minimalFeatureFlagCalledEvent = false) { if (CheckDisabledAndLog(nameof(Capture))) { @@ -321,6 +346,11 @@ bool CaptureCore( capturedEvent.Properties[PostHogProperties.IsServer] = true; } + if (minimalFeatureFlagCalledEvent) + { + capturedEvent = ToMinimalFeatureFlagCalledEvent(capturedEvent); + } + var batchItem = new BatchItem(BatchTask); if (_asyncBatchHandler.Enqueue(batchItem)) @@ -361,6 +391,33 @@ async Task BatchTask(CapturedEventBatchContext context) } } + // Minimal events require both the server-side gate and an explicit has_experiment == false; + // any missing signal (gate off, has_experiment unknown) sends the full event. + static bool ShouldSendMinimalFeatureFlagCalledEvent(bool gate, bool? hasExperiment) + => gate && hasExperiment == false; + + // Builds the minimal event from the allowlist rather than removing properties from the full + // set, so anything merged upstream (super properties, context properties) cannot leak in. The + // CapturedEvent constructor re-adds distinct_id and defaults $geoip_disable to true when it + // was not explicitly set. + static CapturedEvent ToMinimalFeatureFlagCalledEvent(CapturedEvent capturedEvent) + { + var properties = new Dictionary(MinimalFeatureFlagCalledEventProperties.Length); + foreach (var key in MinimalFeatureFlagCalledEventProperties) + { + if (capturedEvent.Properties.TryGetValue(key, out var value)) + { + properties[key] = value; + } + } + + return new CapturedEvent( + capturedEvent.EventName, + capturedEvent.DistinctId, + properties, + capturedEvent.Timestamp); + } + async Task CaptureBatchAsync(IEnumerable batch) { var beforeSend = _options.Value.BeforeSend; @@ -731,12 +788,19 @@ void HandleRemoteError(Exception ex, string errorType) ? _featureFlagsLoader.FlagDefinitionsLoadedAt : null); + // The gate is read from whichever source evaluated the flag. + var minimalFlagCalledEventsGate = flagWasLocallyEvaluated + ? localEvaluator?.MinimalFlagCalledEvents == true + : flagsResult?.MinimalFlagCalledEvents == true; + var minimalEvent = ShouldSendMinimalFeatureFlagCalledEvent(minimalFlagCalledEventsGate, response?.HasExperiment); + TryCaptureDedupedFeatureFlagCalledEvent( resolvedDistinctId, featureKey, cacheKeyValue: (string)response, properties, options.Groups, + minimalEvent, cancellationToken); } @@ -857,6 +921,7 @@ void TryCaptureDedupedFeatureFlagCalledEvent( string cacheKeyValue, Dictionary properties, GroupCollection? groups, + bool minimalEvent, CancellationToken cancellationToken) { var groupsCacheKey = CanonicalGroupsCacheKey(groups); @@ -879,7 +944,8 @@ void TryCaptureDedupedFeatureFlagCalledEvent( groups: groups, sendFeatureFlags: false, flags: null, - timestamp: null); + timestamp: null, + minimalFeatureFlagCalledEvent: minimalEvent); return true; }); @@ -945,12 +1011,18 @@ public void CaptureFeatureFlagCalled( locallyEvaluated: record?.LocallyEvaluated ?? false, flagDefinitionsLoadedAt: record?.LocallyEvaluated == true ? flagDefinitionsLoadedAt : null); + // The gate is carried on the record from the source that evaluated it. + var minimalEvent = ShouldSendMinimalFeatureFlagCalledEvent( + record?.MinimalFlagCalledEvents == true, + record?.Flag.HasExperiment); + _client.TryCaptureDedupedFeatureFlagCalledEvent( distinctId, featureKey, cacheKeyValue, properties, groups, + minimalEvent, CancellationToken.None); } @@ -1013,7 +1085,11 @@ public async Task EvaluateFlagsAsync( foreach (var (key, flag) in locallyEvaluated) { - records[key] = ToRecord(key, flag, locallyEvaluated: true); + records[key] = ToRecord( + key, + flag, + locallyEvaluated: true, + minimalFlagCalledEvents: localEvaluator.MinimalFlagCalledEvents); } if (locallyEvaluated.Count > 0) @@ -1069,7 +1145,11 @@ public async Task EvaluateFlagsAsync( // which discards local results entirely on remote fallback. if (!records.ContainsKey(key)) { - records[key] = ToRecord(key, flag, locallyEvaluated: false); + records[key] = ToRecord( + key, + flag, + locallyEvaluated: false, + minimalFlagCalledEvents: flagsResult.MinimalFlagCalledEvents); } } } @@ -1092,7 +1172,7 @@ and not NullReferenceException options?.Groups, errors); - static EvaluatedFlagRecord ToRecord(string key, FeatureFlag flag, bool locallyEvaluated) + static EvaluatedFlagRecord ToRecord(string key, FeatureFlag flag, bool locallyEvaluated, bool minimalFlagCalledEvents) => new() { Key = key, @@ -1100,6 +1180,7 @@ static EvaluatedFlagRecord ToRecord(string key, FeatureFlag flag, bool locallyEv Enabled = flag.IsEnabled, CacheKeyValue = (string)flag, LocallyEvaluated = locallyEvaluated, + MinimalFlagCalledEvents = minimalFlagCalledEvents, }; } diff --git a/src/PostHog/PublicAPI.Unshipped.txt b/src/PostHog/PublicAPI.Unshipped.txt index ef30ab78..475ff32f 100644 --- a/src/PostHog/PublicAPI.Unshipped.txt +++ b/src/PostHog/PublicAPI.Unshipped.txt @@ -1,6 +1,8 @@ #nullable enable PostHog.AllFeatureFlagsOptions.DisableGeoIp.get -> bool PostHog.AllFeatureFlagsOptions.DisableGeoIp.init -> void +PostHog.Api.FlagsResult.MinimalFlagCalledEvents.get -> bool +PostHog.Api.FlagsResult.MinimalFlagCalledEvents.init -> void PostHog.Features.FeatureFlag.HasExperiment.get -> bool? PostHog.Features.FeatureFlag.HasExperiment.init -> void PostHog.PostHogOptions.BeforeSend.get -> System.Func? diff --git a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs index ae7fbdee..40a643cb 100644 --- a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs @@ -177,6 +177,102 @@ public async Task MixedLocalAndRemoteEvaluationMergesRecordsAndTagsSourceCorrect Assert.False(byFlag["remote-only"]); } + [Fact] + public async Task SendsMinimalFeatureFlagCalledEventsPerRecordWhenGatedAndFlagHasNoExperiment() + { + var container = new TestContainer(personalApiKey: "fake-personal-api-key"); + // local-flag resolves locally; needs-remote and exp-flag fall back to the remote /flags + // response. Both sources report the minimal gate; exp-flag is experiment-linked so it + // still sends the full event. + container.FakeHttpMessageHandler.AddLocalEvaluationResponse( + """ + {"minimal_flag_called_events": true, + "flags": [ + {"id": 1, "key": "local-flag", "active": true, "has_experiment": false, + "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]}}, + {"id": 2, "key": "needs-remote", "active": true, + "filters": {"groups": [{"properties": [{"key": "country", "type": "person", "value": "US", "operator": "exact"}], + "rollout_percentage": 100}]}} + ]} + """); + container.FakeHttpMessageHandler.AddFlagsResponse( + """ + {"minimalFlagCalledEvents": true, + "flags": { + "needs-remote": { + "key": "needs-remote", + "enabled": true, + "reason": {"description": "matched condition set 1"}, + "metadata": {"id": 2, "version": 3, "has_experiment": false} + }, + "exp-flag": { + "key": "exp-flag", + "enabled": true, + "reason": {"description": "matched condition set 1"}, + "metadata": {"id": 3, "version": 1, "has_experiment": true} + } + }} + """); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + + var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + snapshot.IsEnabled("local-flag"); + snapshot.IsEnabled("needs-remote"); + snapshot.IsEnabled("exp-flag"); + + await client.FlushAsync(); + using var doc = JsonDocument.Parse(batchHandler.GetReceivedRequestBody(indented: false)); + var byFlag = doc.RootElement.GetProperty("batch").EnumerateArray() + .ToDictionary( + e => e.GetProperty("properties").GetProperty("$feature_flag").GetString() ?? string.Empty, + e => e.GetProperty("properties")); + + // Minimal events strip the $feature/ enumeration and (for local evaluation) the + // definitions-loaded timestamp. + Assert.False(byFlag["local-flag"].TryGetProperty("$feature/local-flag", out _)); + Assert.False(byFlag["local-flag"].TryGetProperty("$feature_flag_definitions_loaded_at", out _)); + Assert.False(byFlag["local-flag"].GetProperty("$feature_flag_has_experiment").GetBoolean()); + Assert.False(byFlag["needs-remote"].TryGetProperty("$feature/needs-remote", out _)); + Assert.False(byFlag["needs-remote"].GetProperty("$feature_flag_has_experiment").GetBoolean()); + + // Experiment-linked flags keep the full event. + Assert.True(byFlag["exp-flag"].TryGetProperty("$feature/exp-flag", out _)); + Assert.True(byFlag["exp-flag"].GetProperty("$feature_flag_has_experiment").GetBoolean()); + } + + [Fact] + public async Task SendsFullFeatureFlagCalledEventPerRecordWhenHasExperimentIsUnknownEvenWhenGated() + { + var container = new TestContainer(); + // Gate is on, but the flag's metadata omits has_experiment entirely: an unknown signal + // must still fall back to the full event on the snapshot path. + container.FakeHttpMessageHandler.AddFlagsResponse( + """ + {"minimalFlagCalledEvents": true, + "flags": { + "flag-key": { + "key": "flag-key", + "enabled": true, + "reason": {"description": "matched condition set 1"}, + "metadata": {"id": 1, "version": 1} + } + }} + """); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + + var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + snapshot.IsEnabled("flag-key"); + + await client.FlushAsync(); + using var doc = JsonDocument.Parse(batchHandler.GetReceivedRequestBody(indented: false)); + var properties = doc.RootElement.GetProperty("batch").EnumerateArray().Single().GetProperty("properties"); + + Assert.True(properties.TryGetProperty("$feature/flag-key", out _)); + Assert.False(properties.TryGetProperty("$feature_flag_has_experiment", out _)); + } + [Fact] public async Task UnknownKeyAccessAppendsFlagMissingErrorOnFeatureFlagCalled() { diff --git a/tests/UnitTests/Features/FeatureFlagsTests.cs b/tests/UnitTests/Features/FeatureFlagsTests.cs index 870780d0..f90b6240 100644 --- a/tests/UnitTests/Features/FeatureFlagsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagsTests.cs @@ -857,6 +857,340 @@ public async Task CapturesFeatureFlagCalledEventWithHasExperimentFromLocalEvalua } Assert.True(properties.GetProperty("locally_evaluated").GetBoolean()); } + + [Theory] + [InlineData("\"minimalFlagCalledEvents\": true,", "\"has_experiment\": false,", true)] // Gated + no experiment → minimal. + [InlineData("\"minimalFlagCalledEvents\": true,", "\"has_experiment\": true,", false)] // Gated + experiment → full. + [InlineData("\"minimalFlagCalledEvents\": true,", "", false)] // Gated + unknown has_experiment → full. + [InlineData("\"minimalFlagCalledEvents\": false,", "\"has_experiment\": false,", false)] // Gate off → full. + [InlineData("", "\"has_experiment\": false,", false)] // Gate absent → full. + public async Task SendsMinimalFeatureFlagCalledEventOnlyWhenGatedByFlagsResponseAndFlagHasNoExperiment( + string minimalFlagCalledEventsJson, + string hasExperimentJson, + bool expectMinimal) + { + var container = new TestContainer(services => services.Configure(options => + { + options.SuperProperties["source"] = "super"; + })); + var messageHandler = container.FakeHttpMessageHandler; + messageHandler.AddFlagsResponse( + $$""" + { + {{minimalFlagCalledEventsJson}} + "flags": { + "flag-key": { + "key": "flag-key", + "enabled": true, + "variant": null, + "reason": { + "code": "condition_match", + "description": "Matched conditions set 1", + "condition_index": 0 + }, + "metadata": { + "id": 1, + "version": 2, + {{hasExperimentJson}} + "description": "A flag" + } + } + }, + "requestId": "the-request-id", + "evaluatedAt": 1705862903000 + } + """ + ); + var captureRequestHandler = messageHandler.AddBatchResponse(); + var client = container.Activate(); + + Assert.True(await client.IsFeatureEnabledAsync("flag-key", "a-distinct-id")); + + await client.FlushAsync(); + using var document = JsonDocument.Parse(captureRequestHandler.GetReceivedRequestBody(indented: false)); + var properties = document.RootElement.GetProperty("batch") + .EnumerateArray() + .Single() + .GetProperty("properties"); + if (expectMinimal) + { + string[] expectedProperties = + [ + "$feature_flag", + "$feature_flag_response", + "$feature_flag_has_experiment", + "$feature_flag_id", + "$feature_flag_version", + "$feature_flag_reason", + "$feature_flag_request_id", + "$feature_flag_evaluated_at", + "locally_evaluated", + "$lib", + "$lib_version", + "distinct_id", + "$geoip_disable", + "$is_server" + ]; + Assert.Equal( + expectedProperties.OrderBy(name => name, StringComparer.Ordinal), + properties.EnumerateObject().Select(p => p.Name).OrderBy(name => name, StringComparer.Ordinal)); + Assert.False(properties.GetProperty("$feature_flag_has_experiment").GetBoolean()); + Assert.True(properties.GetProperty("$is_server").GetBoolean()); + } + else + { + Assert.True(properties.TryGetProperty("$feature/flag-key", out _)); + Assert.Equal("super", properties.GetProperty("source").GetString()); + } + } + + [Fact] + public async Task MinimalFeatureFlagCalledEventPreservesExplicitGeoIpDisable() + { + var container = new TestContainer(services => services.Configure(options => + { + options.SuperProperties["$geoip_disable"] = false; + })); + var messageHandler = container.FakeHttpMessageHandler; + messageHandler.AddFlagsResponse( + """ + { + "minimalFlagCalledEvents": true, + "flags": { + "flag-key": { + "key": "flag-key", + "enabled": true, + "variant": null, + "reason": { + "code": "condition_match", + "description": "Matched conditions set 1", + "condition_index": 0 + }, + "metadata": { + "id": 1, + "version": 2, + "has_experiment": false, + "description": "A flag" + } + } + } + } + """ + ); + var captureRequestHandler = messageHandler.AddBatchResponse(); + var client = container.Activate(); + + Assert.True(await client.IsFeatureEnabledAsync("flag-key", "a-distinct-id")); + + await client.FlushAsync(); + using var document = JsonDocument.Parse(captureRequestHandler.GetReceivedRequestBody(indented: false)); + var properties = document.RootElement.GetProperty("batch") + .EnumerateArray() + .Single() + .GetProperty("properties"); + // The event is minimal, but the caller's explicit GeoIP choice survives the allowlist. + Assert.False(properties.TryGetProperty("$feature/flag-key", out _)); + Assert.False(properties.GetProperty("$geoip_disable").GetBoolean()); + } + + [Fact] + public async Task MinimalFeatureFlagCalledEventRetainsGroups() + { + var container = new TestContainer(); + var messageHandler = container.FakeHttpMessageHandler; + messageHandler.AddFlagsResponse( + """ + { + "minimalFlagCalledEvents": true, + "flags": { + "flag-key": { + "key": "flag-key", + "enabled": true, + "variant": null, + "reason": { + "code": "condition_match", + "description": "Matched conditions set 1", + "condition_index": 0 + }, + "metadata": { + "id": 1, + "version": 2, + "has_experiment": false, + "description": "A flag" + } + } + }, + "requestId": "the-request-id", + "evaluatedAt": 1705862903000 + } + """ + ); + var captureRequestHandler = messageHandler.AddBatchResponse(); + var client = container.Activate(); + + Assert.True(await client.IsFeatureEnabledAsync( + "flag-key", + "a-distinct-id", + options: new FeatureFlagOptions + { + Groups = [new Group { GroupType = "company", GroupKey = "id:5" }] + })); + + await client.FlushAsync(); + using var document = JsonDocument.Parse(captureRequestHandler.GetReceivedRequestBody(indented: false)); + var properties = document.RootElement.GetProperty("batch") + .EnumerateArray() + .Single() + .GetProperty("properties"); + string[] expectedProperties = + [ + "$feature_flag", + "$feature_flag_response", + "$feature_flag_has_experiment", + "$feature_flag_id", + "$feature_flag_version", + "$feature_flag_reason", + "$feature_flag_request_id", + "$feature_flag_evaluated_at", + "locally_evaluated", + "$groups", + "$lib", + "$lib_version", + "distinct_id", + "$geoip_disable", + "$is_server" + ]; + Assert.Equal( + expectedProperties.OrderBy(name => name, StringComparer.Ordinal), + properties.EnumerateObject().Select(p => p.Name).OrderBy(name => name, StringComparer.Ordinal)); + Assert.Equal("id:5", properties.GetProperty("$groups").GetProperty("company").GetString()); + } + + [Fact] + public async Task MinimalFeatureFlagCalledEventRetainsErrorProperty() + { + var container = new TestContainer(); + var messageHandler = container.FakeHttpMessageHandler; + messageHandler.AddFlagsResponse( + """ + { + "minimalFlagCalledEvents": true, + "errorsWhileComputingFlags": true, + "flags": { + "flag-key": { + "key": "flag-key", + "enabled": true, + "variant": null, + "reason": { + "code": "condition_match", + "description": "Matched conditions set 1", + "condition_index": 0 + }, + "metadata": { + "id": 1, + "version": 2, + "has_experiment": false, + "description": "A flag" + } + } + } + } + """ + ); + var captureRequestHandler = messageHandler.AddBatchResponse(); + var client = container.Activate(); + + Assert.True(await client.IsFeatureEnabledAsync("flag-key", "a-distinct-id")); + + await client.FlushAsync(); + using var document = JsonDocument.Parse(captureRequestHandler.GetReceivedRequestBody(indented: false)); + var properties = document.RootElement.GetProperty("batch") + .EnumerateArray() + .Single() + .GetProperty("properties"); + // The event is minimal, but $feature_flag_error (allowlisted) still survives the strip. + Assert.False(properties.TryGetProperty("$feature/flag-key", out _)); + Assert.Equal("errors_while_computing_flags", properties.GetProperty("$feature_flag_error").GetString()); + } + + [Theory] + [InlineData("\"minimal_flag_called_events\": true,", "\"has_experiment\": false,", true)] // Gated + no experiment → minimal. + [InlineData("\"minimal_flag_called_events\": true,", "\"has_experiment\": true,", false)] // Gated + experiment → full. + [InlineData("\"minimal_flag_called_events\": true,", "", false)] // Gated + unknown has_experiment → full. + [InlineData("", "\"has_experiment\": false,", false)] // Gate absent → full. + public async Task SendsMinimalFeatureFlagCalledEventOnlyWhenGatedByLocalEvaluationAndFlagHasNoExperiment( + string minimalFlagCalledEventsJson, + string hasExperimentJson, + bool expectMinimal) + { + var container = new TestContainer(services => services.Configure(options => + { + options.SecretKey = "fake-personal-api-key"; + options.SuperProperties["source"] = "super"; + })); + var messageHandler = container.FakeHttpMessageHandler; + messageHandler.AddLocalEvaluationResponse( + $$""" + { + {{minimalFlagCalledEventsJson}} + "flags": [ + { + "id": 1, + "key": "flag-key", + "active": true, + {{hasExperimentJson}} + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ] + } + } + ] + } + """ + ); + var captureRequestHandler = messageHandler.AddBatchResponse(); + var client = container.Activate(); + + Assert.True(await client.IsFeatureEnabledAsync("flag-key", "a-distinct-id")); + + await client.FlushAsync(); + using var document = JsonDocument.Parse(captureRequestHandler.GetReceivedRequestBody(indented: false)); + var properties = document.RootElement.GetProperty("batch") + .EnumerateArray() + .Single() + .GetProperty("properties"); + if (expectMinimal) + { + string[] expectedProperties = + [ + "$feature_flag", + "$feature_flag_response", + "$feature_flag_has_experiment", + "$feature_flag_reason", + "locally_evaluated", + "$lib", + "$lib_version", + "distinct_id", + "$geoip_disable", + "$is_server" + ]; + Assert.Equal( + expectedProperties.OrderBy(name => name, StringComparer.Ordinal), + properties.EnumerateObject().Select(p => p.Name).OrderBy(name => name, StringComparer.Ordinal)); + Assert.False(properties.GetProperty("$feature_flag_has_experiment").GetBoolean()); + Assert.True(properties.GetProperty("locally_evaluated").GetBoolean()); + } + else + { + Assert.True(properties.TryGetProperty("$feature/flag-key", out _)); + Assert.True(properties.TryGetProperty("$feature_flag_definitions_loaded_at", out _)); + Assert.Equal("super", properties.GetProperty("source").GetString()); + } + } } public class TheGetFeatureFlagAsyncMethod