From d1c877d1c1473beb4d5394ddad73d6b31e38f6f0 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 16 Jul 2026 10:29:04 -0700 Subject: [PATCH 1/2] feat(flags): send minimal $feature_flag_called events when server-gated Emit a minimal $feature_flag_called event when the server enables the gate (minimalFlagCalledEvents in the /flags v2 response, or minimal_flag_called_events in the local evaluation payload) and the evaluated flag reports has_experiment == false. Minimal events keep a strict allowlist of flag evaluation properties and strip everything else, including the $feature/ enumeration and super properties. Any missing signal (gate absent, has_experiment unknown, legacy response shapes) sends the full event, exactly as before. Generated-By: PostHog Code Task-Id: ffe402fd-d75c-4043-8e5d-d2fe513cac6f --- .changeset/minimal-flag-called-events.md | 5 + src/PostHog/Api/FlagsApiResult.cs | 15 +- src/PostHog/Api/LocalEvaluationApiResult.cs | 12 +- src/PostHog/Features/EvaluatedFlagRecord.cs | 7 + src/PostHog/Features/LocalEvaluator.cs | 6 + src/PostHog/PostHogClient.cs | 88 +++++- src/PostHog/PublicAPI.Unshipped.txt | 2 + .../Features/FeatureFlagEvaluationsTests.cs | 64 ++++ tests/UnitTests/Features/FeatureFlagsTests.cs | 287 ++++++++++++++++++ 9 files changed, 478 insertions(+), 8 deletions(-) create mode 100644 .changeset/minimal-flag-called-events.md 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..88ce6d5d 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,28 @@ async Task BatchTask(CapturedEventBatchContext context) } } + // 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 +783,21 @@ void HandleRemoteError(Exception ex, string errorType) ? _featureFlagsLoader.FlagDefinitionsLoadedAt : null); + // Minimal events require both the server-side gate (read from whichever source + // evaluated the flag) and an explicit has_experiment == false; any missing signal + // sends the full event. + var minimalEvent = response?.HasExperiment == false + && (flagWasLocallyEvaluated + ? localEvaluator?.MinimalFlagCalledEvents == true + : flagsResult?.MinimalFlagCalledEvents == true); + TryCaptureDedupedFeatureFlagCalledEvent( resolvedDistinctId, featureKey, cacheKeyValue: (string)response, properties, options.Groups, + minimalEvent, cancellationToken); } @@ -857,6 +918,7 @@ void TryCaptureDedupedFeatureFlagCalledEvent( string cacheKeyValue, Dictionary properties, GroupCollection? groups, + bool minimalEvent, CancellationToken cancellationToken) { var groupsCacheKey = CanonicalGroupsCacheKey(groups); @@ -879,7 +941,8 @@ void TryCaptureDedupedFeatureFlagCalledEvent( groups: groups, sendFeatureFlags: false, flags: null, - timestamp: null); + timestamp: null, + minimalFeatureFlagCalledEvent: minimalEvent); return true; }); @@ -945,12 +1008,18 @@ public void CaptureFeatureFlagCalled( locallyEvaluated: record?.LocallyEvaluated ?? false, flagDefinitionsLoadedAt: record?.LocallyEvaluated == true ? flagDefinitionsLoadedAt : null); + // Minimal events require both the server-side gate (carried on the record from the + // source that evaluated it) and an explicit has_experiment == false; any missing + // signal sends the full event. + var minimalEvent = record is { MinimalFlagCalledEvents: true, Flag.HasExperiment: false }; + _client.TryCaptureDedupedFeatureFlagCalledEvent( distinctId, featureKey, cacheKeyValue, properties, groups, + minimalEvent, CancellationToken.None); } @@ -1013,7 +1082,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 +1142,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 +1169,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 +1177,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..ed5f5773 100644 --- a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs @@ -177,6 +177,70 @@ 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 UnknownKeyAccessAppendsFlagMissingErrorOnFeatureFlagCalled() { diff --git a/tests/UnitTests/Features/FeatureFlagsTests.cs b/tests/UnitTests/Features/FeatureFlagsTests.cs index 870780d0..49d40deb 100644 --- a/tests/UnitTests/Features/FeatureFlagsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagsTests.cs @@ -857,6 +857,293 @@ 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()); + } + + [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 From e32a27f89d1528d0e296534b0e67770951253b40 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Fri, 17 Jul 2026 21:23:15 -0700 Subject: [PATCH 2/2] Extract shared minimal-event gate predicate and add coverage Extract ShouldSendMinimalFeatureFlagCalledEvent(gate, hasExperiment) to remove the duplicated inline rule at both call sites. Add coverage for the snapshot path's fallback to the full event when has_experiment is unknown, and for $feature_flag_error surviving the minimal-event allowlist. --- src/PostHog/PostHogClient.cs | 25 +++++----- .../Features/FeatureFlagEvaluationsTests.cs | 32 +++++++++++++ tests/UnitTests/Features/FeatureFlagsTests.cs | 47 +++++++++++++++++++ 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index 88ce6d5d..9bf20930 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -391,6 +391,11 @@ 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 @@ -783,13 +788,11 @@ void HandleRemoteError(Exception ex, string errorType) ? _featureFlagsLoader.FlagDefinitionsLoadedAt : null); - // Minimal events require both the server-side gate (read from whichever source - // evaluated the flag) and an explicit has_experiment == false; any missing signal - // sends the full event. - var minimalEvent = response?.HasExperiment == false - && (flagWasLocallyEvaluated - ? localEvaluator?.MinimalFlagCalledEvents == true - : flagsResult?.MinimalFlagCalledEvents == true); + // 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, @@ -1008,10 +1011,10 @@ public void CaptureFeatureFlagCalled( locallyEvaluated: record?.LocallyEvaluated ?? false, flagDefinitionsLoadedAt: record?.LocallyEvaluated == true ? flagDefinitionsLoadedAt : null); - // Minimal events require both the server-side gate (carried on the record from the - // source that evaluated it) and an explicit has_experiment == false; any missing - // signal sends the full event. - var minimalEvent = record is { MinimalFlagCalledEvents: true, Flag.HasExperiment: false }; + // 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, diff --git a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs index ed5f5773..40a643cb 100644 --- a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs @@ -241,6 +241,38 @@ public async Task SendsMinimalFeatureFlagCalledEventsPerRecordWhenGatedAndFlagHa 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 49d40deb..f90b6240 100644 --- a/tests/UnitTests/Features/FeatureFlagsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagsTests.cs @@ -1066,6 +1066,53 @@ public async Task MinimalFeatureFlagCalledEventRetainsGroups() 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.