Skip to content

Commit dd92ea1

Browse files
authored
feat(flags): minimize $feature_flag_called events for non-experiment flags (#263)
* 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/<key> 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 * 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.
1 parent 9119c94 commit dd92ea1

9 files changed

Lines changed: 560 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"PostHog": minor
3+
---
4+
5+
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/<key>` enumeration and super properties. Experiment-linked flags and responses that do not carry the field continue to send the full event.

src/PostHog/Api/FlagsApiResult.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ internal record FlagsApiResult
4444
/// The timestamp when the feature flags were evaluated (milliseconds since Unix epoch).
4545
/// </summary>
4646
public long? EvaluatedAt { get; init; }
47+
48+
/// <summary>
49+
/// Whether the server gated this project into sending minimal <c>$feature_flag_called</c> events.
50+
/// <c>null</c> when the server does not report the field (older deployments and legacy response formats).
51+
/// </summary>
52+
public bool? MinimalFlagCalledEvents { get; init; }
4753
}
4854

4955
/// <summary>
@@ -78,6 +84,12 @@ public record FlagsResult
7884
/// The list of feature flags that are limited by quota.
7985
/// </summary>
8086
public IReadOnlyList<string> QuotaLimited { get; init; } = [];
87+
88+
/// <summary>
89+
/// Whether the server gated this project into sending minimal <c>$feature_flag_called</c> events.
90+
/// <c>false</c> when the response does not carry the field.
91+
/// </summary>
92+
public bool MinimalFlagCalledEvents { get; init; }
8193
}
8294

8395
/// <summary>
@@ -129,7 +141,8 @@ public static FlagsResult ToFlagsResult(this FlagsApiResult? results)
129141
ErrorsWhileComputingFlags = results.ErrorsWhileComputingFlags,
130142
RequestId = results.RequestId,
131143
EvaluatedAt = results.EvaluatedAt,
132-
QuotaLimited = results.QuotaLimited ?? []
144+
QuotaLimited = results.QuotaLimited ?? [],
145+
MinimalFlagCalledEvents = results.MinimalFlagCalledEvents == true
133146
};
134147
}
135148
}

src/PostHog/Api/LocalEvaluationApiResult.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ internal record LocalEvaluationApiResult
2626
/// </summary>
2727
public IReadOnlyDictionary<string, FilterSet>? Cohorts { get; init; }
2828

29+
/// <summary>
30+
/// Whether the server gated this project into sending minimal <c>$feature_flag_called</c> events.
31+
/// <c>null</c> when the server does not report the field (older deployments).
32+
/// </summary>
33+
[JsonPropertyName("minimal_flag_called_events")]
34+
public bool? MinimalFlagCalledEvents { get; init; }
35+
2936
/// <summary>
3037
/// Compares this instance to another <see cref="LocalEvaluationApiResult"/> for equality.
3138
/// </summary>
@@ -48,14 +55,15 @@ public virtual bool Equals(LocalEvaluationApiResult? other)
4855

4956
return Flags.ListsAreEqual(other.Flags)
5057
&& GroupTypeMapping.DictionariesAreEqual(other.GroupTypeMapping)
51-
&& Cohorts.DictionariesAreEqual(other.Cohorts);
58+
&& Cohorts.DictionariesAreEqual(other.Cohorts)
59+
&& MinimalFlagCalledEvents == other.MinimalFlagCalledEvents;
5260
}
5361

5462
/// <summary>
5563
/// Serves as the default hash function.
5664
/// </summary>
5765
/// <returns>A hash code for the current object.</returns>
58-
public override int GetHashCode() => HashCode.Combine(Flags, GroupTypeMapping, Cohorts);
66+
public override int GetHashCode() => HashCode.Combine(Flags, GroupTypeMapping, Cohorts, MinimalFlagCalledEvents);
5967
}
6068

6169
/// <summary>

src/PostHog/Features/EvaluatedFlagRecord.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,11 @@ internal sealed record EvaluatedFlagRecord
3535
/// on the emitted <c>$feature_flag_called</c> event.
3636
/// </summary>
3737
public bool LocallyEvaluated { get; init; }
38+
39+
/// <summary>
40+
/// Whether the server gated this project into sending minimal <c>$feature_flag_called</c> events,
41+
/// as reported by the source that evaluated this record (the local definitions payload or the
42+
/// <c>/flags</c> response).
43+
/// </summary>
44+
public bool MinimalFlagCalledEvents { get; init; }
3845
}

src/PostHog/Features/LocalEvaluator.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ public bool TryGetLocalFeatureFlag(string key, [NotNullWhen(returnValue: true)]
7272
/// </summary>
7373
public LocalEvaluationApiResult LocalEvaluationApiResult { get; }
7474

75+
/// <summary>
76+
/// Whether the server gated this project into sending minimal <c>$feature_flag_called</c> events,
77+
/// as reported by the local evaluation payload. <c>false</c> when the payload does not carry the field.
78+
/// </summary>
79+
public bool MinimalFlagCalledEvents => LocalEvaluationApiResult.MinimalFlagCalledEvents == true;
80+
7581
/// <summary>
7682
/// Constructs a <see cref="LocalEvaluator"/> with the specified flags.
7783
/// </summary>

src/PostHog/PostHogClient.cs

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,30 @@ public sealed class PostHogClient : IPostHogClient
2525
static readonly ApiResult NoOpApiResult = new(0);
2626
static readonly IReadOnlyDictionary<string, FeatureFlag> EmptyFeatureFlags = new Dictionary<string, FeatureFlag>(0);
2727

28+
// Strict allowlist for minimal $feature_flag_called events, shared across PostHog SDKs.
29+
// Everything else — including super properties, context properties, the $feature/<key>
30+
// enumeration, and system context — is stripped.
31+
static readonly string[] MinimalFeatureFlagCalledEventProperties =
32+
[
33+
"$feature_flag",
34+
"$feature_flag_response",
35+
"$feature_flag_has_experiment",
36+
"$feature_flag_id",
37+
"$feature_flag_version",
38+
"$feature_flag_reason",
39+
"$feature_flag_request_id",
40+
"$feature_flag_evaluated_at",
41+
"$feature_flag_error",
42+
"locally_evaluated",
43+
"$groups",
44+
PostHogProperties.ProcessPersonProfile,
45+
PostHogProperties.SessionId,
46+
PostHogProperties.Lib,
47+
PostHogProperties.LibVersion,
48+
PostHogProperties.GeoIpDisable,
49+
PostHogProperties.IsServer,
50+
];
51+
2852
readonly TimeProvider _timeProvider;
2953
readonly IOptions<PostHogOptions> _options;
3054
readonly ITaskScheduler _taskScheduler;
@@ -278,7 +302,8 @@ bool CaptureCore(
278302
GroupCollection? groups,
279303
bool sendFeatureFlags,
280304
FeatureFlagEvaluations? flags,
281-
DateTimeOffset? timestamp)
305+
DateTimeOffset? timestamp,
306+
bool minimalFeatureFlagCalledEvent = false)
282307
{
283308
if (CheckDisabledAndLog(nameof(Capture)))
284309
{
@@ -321,6 +346,11 @@ bool CaptureCore(
321346
capturedEvent.Properties[PostHogProperties.IsServer] = true;
322347
}
323348

349+
if (minimalFeatureFlagCalledEvent)
350+
{
351+
capturedEvent = ToMinimalFeatureFlagCalledEvent(capturedEvent);
352+
}
353+
324354
var batchItem = new BatchItem<CapturedEvent, CapturedEventBatchContext>(BatchTask);
325355

326356
if (_asyncBatchHandler.Enqueue(batchItem))
@@ -361,6 +391,33 @@ async Task<CapturedEvent> BatchTask(CapturedEventBatchContext context)
361391
}
362392
}
363393

394+
// Minimal events require both the server-side gate and an explicit has_experiment == false;
395+
// any missing signal (gate off, has_experiment unknown) sends the full event.
396+
static bool ShouldSendMinimalFeatureFlagCalledEvent(bool gate, bool? hasExperiment)
397+
=> gate && hasExperiment == false;
398+
399+
// Builds the minimal event from the allowlist rather than removing properties from the full
400+
// set, so anything merged upstream (super properties, context properties) cannot leak in. The
401+
// CapturedEvent constructor re-adds distinct_id and defaults $geoip_disable to true when it
402+
// was not explicitly set.
403+
static CapturedEvent ToMinimalFeatureFlagCalledEvent(CapturedEvent capturedEvent)
404+
{
405+
var properties = new Dictionary<string, object>(MinimalFeatureFlagCalledEventProperties.Length);
406+
foreach (var key in MinimalFeatureFlagCalledEventProperties)
407+
{
408+
if (capturedEvent.Properties.TryGetValue(key, out var value))
409+
{
410+
properties[key] = value;
411+
}
412+
}
413+
414+
return new CapturedEvent(
415+
capturedEvent.EventName,
416+
capturedEvent.DistinctId,
417+
properties,
418+
capturedEvent.Timestamp);
419+
}
420+
364421
async Task CaptureBatchAsync(IEnumerable<CapturedEvent> batch)
365422
{
366423
var beforeSend = _options.Value.BeforeSend;
@@ -731,12 +788,19 @@ void HandleRemoteError(Exception ex, string errorType)
731788
? _featureFlagsLoader.FlagDefinitionsLoadedAt
732789
: null);
733790

791+
// The gate is read from whichever source evaluated the flag.
792+
var minimalFlagCalledEventsGate = flagWasLocallyEvaluated
793+
? localEvaluator?.MinimalFlagCalledEvents == true
794+
: flagsResult?.MinimalFlagCalledEvents == true;
795+
var minimalEvent = ShouldSendMinimalFeatureFlagCalledEvent(minimalFlagCalledEventsGate, response?.HasExperiment);
796+
734797
TryCaptureDedupedFeatureFlagCalledEvent(
735798
resolvedDistinctId,
736799
featureKey,
737800
cacheKeyValue: (string)response,
738801
properties,
739802
options.Groups,
803+
minimalEvent,
740804
cancellationToken);
741805
}
742806

@@ -857,6 +921,7 @@ void TryCaptureDedupedFeatureFlagCalledEvent(
857921
string cacheKeyValue,
858922
Dictionary<string, object> properties,
859923
GroupCollection? groups,
924+
bool minimalEvent,
860925
CancellationToken cancellationToken)
861926
{
862927
var groupsCacheKey = CanonicalGroupsCacheKey(groups);
@@ -879,7 +944,8 @@ void TryCaptureDedupedFeatureFlagCalledEvent(
879944
groups: groups,
880945
sendFeatureFlags: false,
881946
flags: null,
882-
timestamp: null);
947+
timestamp: null,
948+
minimalFeatureFlagCalledEvent: minimalEvent);
883949
return true;
884950
});
885951

@@ -945,12 +1011,18 @@ public void CaptureFeatureFlagCalled(
9451011
locallyEvaluated: record?.LocallyEvaluated ?? false,
9461012
flagDefinitionsLoadedAt: record?.LocallyEvaluated == true ? flagDefinitionsLoadedAt : null);
9471013

1014+
// The gate is carried on the record from the source that evaluated it.
1015+
var minimalEvent = ShouldSendMinimalFeatureFlagCalledEvent(
1016+
record?.MinimalFlagCalledEvents == true,
1017+
record?.Flag.HasExperiment);
1018+
9481019
_client.TryCaptureDedupedFeatureFlagCalledEvent(
9491020
distinctId,
9501021
featureKey,
9511022
cacheKeyValue,
9521023
properties,
9531024
groups,
1025+
minimalEvent,
9541026
CancellationToken.None);
9551027
}
9561028

@@ -1013,7 +1085,11 @@ public async Task<FeatureFlagEvaluations> EvaluateFlagsAsync(
10131085

10141086
foreach (var (key, flag) in locallyEvaluated)
10151087
{
1016-
records[key] = ToRecord(key, flag, locallyEvaluated: true);
1088+
records[key] = ToRecord(
1089+
key,
1090+
flag,
1091+
locallyEvaluated: true,
1092+
minimalFlagCalledEvents: localEvaluator.MinimalFlagCalledEvents);
10171093
}
10181094

10191095
if (locallyEvaluated.Count > 0)
@@ -1069,7 +1145,11 @@ public async Task<FeatureFlagEvaluations> EvaluateFlagsAsync(
10691145
// which discards local results entirely on remote fallback.
10701146
if (!records.ContainsKey(key))
10711147
{
1072-
records[key] = ToRecord(key, flag, locallyEvaluated: false);
1148+
records[key] = ToRecord(
1149+
key,
1150+
flag,
1151+
locallyEvaluated: false,
1152+
minimalFlagCalledEvents: flagsResult.MinimalFlagCalledEvents);
10731153
}
10741154
}
10751155
}
@@ -1092,14 +1172,15 @@ and not NullReferenceException
10921172
options?.Groups,
10931173
errors);
10941174

1095-
static EvaluatedFlagRecord ToRecord(string key, FeatureFlag flag, bool locallyEvaluated)
1175+
static EvaluatedFlagRecord ToRecord(string key, FeatureFlag flag, bool locallyEvaluated, bool minimalFlagCalledEvents)
10961176
=> new()
10971177
{
10981178
Key = key,
10991179
Flag = flag,
11001180
Enabled = flag.IsEnabled,
11011181
CacheKeyValue = (string)flag,
11021182
LocallyEvaluated = locallyEvaluated,
1183+
MinimalFlagCalledEvents = minimalFlagCalledEvents,
11031184
};
11041185
}
11051186

src/PostHog/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#nullable enable
22
PostHog.AllFeatureFlagsOptions.DisableGeoIp.get -> bool
33
PostHog.AllFeatureFlagsOptions.DisableGeoIp.init -> void
4+
PostHog.Api.FlagsResult.MinimalFlagCalledEvents.get -> bool
5+
PostHog.Api.FlagsResult.MinimalFlagCalledEvents.init -> void
46
PostHog.Features.FeatureFlag.HasExperiment.get -> bool?
57
PostHog.Features.FeatureFlag.HasExperiment.init -> void
68
PostHog.PostHogOptions.BeforeSend.get -> System.Func<PostHog.Api.CapturedEvent!, PostHog.Api.CapturedEvent?>?

tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,102 @@ public async Task MixedLocalAndRemoteEvaluationMergesRecordsAndTagsSourceCorrect
177177
Assert.False(byFlag["remote-only"]);
178178
}
179179

180+
[Fact]
181+
public async Task SendsMinimalFeatureFlagCalledEventsPerRecordWhenGatedAndFlagHasNoExperiment()
182+
{
183+
var container = new TestContainer(personalApiKey: "fake-personal-api-key");
184+
// local-flag resolves locally; needs-remote and exp-flag fall back to the remote /flags
185+
// response. Both sources report the minimal gate; exp-flag is experiment-linked so it
186+
// still sends the full event.
187+
container.FakeHttpMessageHandler.AddLocalEvaluationResponse(
188+
"""
189+
{"minimal_flag_called_events": true,
190+
"flags": [
191+
{"id": 1, "key": "local-flag", "active": true, "has_experiment": false,
192+
"filters": {"groups": [{"properties": [], "rollout_percentage": 100}]}},
193+
{"id": 2, "key": "needs-remote", "active": true,
194+
"filters": {"groups": [{"properties": [{"key": "country", "type": "person", "value": "US", "operator": "exact"}],
195+
"rollout_percentage": 100}]}}
196+
]}
197+
""");
198+
container.FakeHttpMessageHandler.AddFlagsResponse(
199+
"""
200+
{"minimalFlagCalledEvents": true,
201+
"flags": {
202+
"needs-remote": {
203+
"key": "needs-remote",
204+
"enabled": true,
205+
"reason": {"description": "matched condition set 1"},
206+
"metadata": {"id": 2, "version": 3, "has_experiment": false}
207+
},
208+
"exp-flag": {
209+
"key": "exp-flag",
210+
"enabled": true,
211+
"reason": {"description": "matched condition set 1"},
212+
"metadata": {"id": 3, "version": 1, "has_experiment": true}
213+
}
214+
}}
215+
""");
216+
var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse();
217+
var client = container.Activate<PostHogClient>();
218+
219+
var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None);
220+
snapshot.IsEnabled("local-flag");
221+
snapshot.IsEnabled("needs-remote");
222+
snapshot.IsEnabled("exp-flag");
223+
224+
await client.FlushAsync();
225+
using var doc = JsonDocument.Parse(batchHandler.GetReceivedRequestBody(indented: false));
226+
var byFlag = doc.RootElement.GetProperty("batch").EnumerateArray()
227+
.ToDictionary(
228+
e => e.GetProperty("properties").GetProperty("$feature_flag").GetString() ?? string.Empty,
229+
e => e.GetProperty("properties"));
230+
231+
// Minimal events strip the $feature/<key> enumeration and (for local evaluation) the
232+
// definitions-loaded timestamp.
233+
Assert.False(byFlag["local-flag"].TryGetProperty("$feature/local-flag", out _));
234+
Assert.False(byFlag["local-flag"].TryGetProperty("$feature_flag_definitions_loaded_at", out _));
235+
Assert.False(byFlag["local-flag"].GetProperty("$feature_flag_has_experiment").GetBoolean());
236+
Assert.False(byFlag["needs-remote"].TryGetProperty("$feature/needs-remote", out _));
237+
Assert.False(byFlag["needs-remote"].GetProperty("$feature_flag_has_experiment").GetBoolean());
238+
239+
// Experiment-linked flags keep the full event.
240+
Assert.True(byFlag["exp-flag"].TryGetProperty("$feature/exp-flag", out _));
241+
Assert.True(byFlag["exp-flag"].GetProperty("$feature_flag_has_experiment").GetBoolean());
242+
}
243+
244+
[Fact]
245+
public async Task SendsFullFeatureFlagCalledEventPerRecordWhenHasExperimentIsUnknownEvenWhenGated()
246+
{
247+
var container = new TestContainer();
248+
// Gate is on, but the flag's metadata omits has_experiment entirely: an unknown signal
249+
// must still fall back to the full event on the snapshot path.
250+
container.FakeHttpMessageHandler.AddFlagsResponse(
251+
"""
252+
{"minimalFlagCalledEvents": true,
253+
"flags": {
254+
"flag-key": {
255+
"key": "flag-key",
256+
"enabled": true,
257+
"reason": {"description": "matched condition set 1"},
258+
"metadata": {"id": 1, "version": 1}
259+
}
260+
}}
261+
""");
262+
var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse();
263+
var client = container.Activate<PostHogClient>();
264+
265+
var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None);
266+
snapshot.IsEnabled("flag-key");
267+
268+
await client.FlushAsync();
269+
using var doc = JsonDocument.Parse(batchHandler.GetReceivedRequestBody(indented: false));
270+
var properties = doc.RootElement.GetProperty("batch").EnumerateArray().Single().GetProperty("properties");
271+
272+
Assert.True(properties.TryGetProperty("$feature/flag-key", out _));
273+
Assert.False(properties.TryGetProperty("$feature_flag_has_experiment", out _));
274+
}
275+
180276
[Fact]
181277
public async Task UnknownKeyAccessAppendsFlagMissingErrorOnFeatureFlagCalled()
182278
{

0 commit comments

Comments
 (0)