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
5 changes: 5 additions & 0 deletions .changeset/minimal-flag-called-events.md
Original file line number Diff line number Diff line change
@@ -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/<key>` enumeration and super properties. Experiment-linked flags and responses that do not carry the field continue to send the full event.
15 changes: 14 additions & 1 deletion src/PostHog/Api/FlagsApiResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ internal record FlagsApiResult
/// The timestamp when the feature flags were evaluated (milliseconds since Unix epoch).
/// </summary>
public long? EvaluatedAt { get; init; }

/// <summary>
/// Whether the server gated this project into sending minimal <c>$feature_flag_called</c> events.
/// <c>null</c> when the server does not report the field (older deployments and legacy response formats).
/// </summary>
public bool? MinimalFlagCalledEvents { get; init; }
}

/// <summary>
Expand Down Expand Up @@ -78,6 +84,12 @@ public record FlagsResult
/// The list of feature flags that are limited by quota.
/// </summary>
public IReadOnlyList<string> QuotaLimited { get; init; } = [];

/// <summary>
/// Whether the server gated this project into sending minimal <c>$feature_flag_called</c> events.
/// <c>false</c> when the response does not carry the field.
/// </summary>
public bool MinimalFlagCalledEvents { get; init; }
}

/// <summary>
Expand Down Expand Up @@ -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
};
}
}
12 changes: 10 additions & 2 deletions src/PostHog/Api/LocalEvaluationApiResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ internal record LocalEvaluationApiResult
/// </summary>
public IReadOnlyDictionary<string, FilterSet>? Cohorts { get; init; }

/// <summary>
/// Whether the server gated this project into sending minimal <c>$feature_flag_called</c> events.
/// <c>null</c> when the server does not report the field (older deployments).
/// </summary>
[JsonPropertyName("minimal_flag_called_events")]
public bool? MinimalFlagCalledEvents { get; init; }

/// <summary>
/// Compares this instance to another <see cref="LocalEvaluationApiResult"/> for equality.
/// </summary>
Expand All @@ -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;
}

/// <summary>
/// Serves as the default hash function.
/// </summary>
/// <returns>A hash code for the current object.</returns>
public override int GetHashCode() => HashCode.Combine(Flags, GroupTypeMapping, Cohorts);
public override int GetHashCode() => HashCode.Combine(Flags, GroupTypeMapping, Cohorts, MinimalFlagCalledEvents);
}

/// <summary>
Expand Down
7 changes: 7 additions & 0 deletions src/PostHog/Features/EvaluatedFlagRecord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,11 @@ internal sealed record EvaluatedFlagRecord
/// on the emitted <c>$feature_flag_called</c> event.
/// </summary>
public bool LocallyEvaluated { get; init; }

/// <summary>
/// Whether the server gated this project into sending minimal <c>$feature_flag_called</c> events,
/// as reported by the source that evaluated this record (the local definitions payload or the
/// <c>/flags</c> response).
/// </summary>
public bool MinimalFlagCalledEvents { get; init; }
}
6 changes: 6 additions & 0 deletions src/PostHog/Features/LocalEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ public bool TryGetLocalFeatureFlag(string key, [NotNullWhen(returnValue: true)]
/// </summary>
public LocalEvaluationApiResult LocalEvaluationApiResult { get; }

/// <summary>
/// Whether the server gated this project into sending minimal <c>$feature_flag_called</c> events,
/// as reported by the local evaluation payload. <c>false</c> when the payload does not carry the field.
/// </summary>
public bool MinimalFlagCalledEvents => LocalEvaluationApiResult.MinimalFlagCalledEvents == true;

/// <summary>
/// Constructs a <see cref="LocalEvaluator"/> with the specified flags.
/// </summary>
Expand Down
91 changes: 86 additions & 5 deletions src/PostHog/PostHogClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,30 @@ public sealed class PostHogClient : IPostHogClient
static readonly ApiResult NoOpApiResult = new(0);
static readonly IReadOnlyDictionary<string, FeatureFlag> EmptyFeatureFlags = new Dictionary<string, FeatureFlag>(0);

// Strict allowlist for minimal $feature_flag_called events, shared across PostHog SDKs.
// Everything else — including super properties, context properties, the $feature/<key>
// 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<PostHogOptions> _options;
readonly ITaskScheduler _taskScheduler;
Expand Down Expand Up @@ -278,7 +302,8 @@ bool CaptureCore(
GroupCollection? groups,
bool sendFeatureFlags,
FeatureFlagEvaluations? flags,
DateTimeOffset? timestamp)
DateTimeOffset? timestamp,
bool minimalFeatureFlagCalledEvent = false)
{
if (CheckDisabledAndLog(nameof(Capture)))
{
Expand Down Expand Up @@ -321,6 +346,11 @@ bool CaptureCore(
capturedEvent.Properties[PostHogProperties.IsServer] = true;
}

if (minimalFeatureFlagCalledEvent)
{
capturedEvent = ToMinimalFeatureFlagCalledEvent(capturedEvent);
}

var batchItem = new BatchItem<CapturedEvent, CapturedEventBatchContext>(BatchTask);

if (_asyncBatchHandler.Enqueue(batchItem))
Expand Down Expand Up @@ -361,6 +391,33 @@ async Task<CapturedEvent> 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<string, object>(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<CapturedEvent> batch)
{
var beforeSend = _options.Value.BeforeSend;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -857,6 +921,7 @@ void TryCaptureDedupedFeatureFlagCalledEvent(
string cacheKeyValue,
Dictionary<string, object> properties,
GroupCollection? groups,
bool minimalEvent,
CancellationToken cancellationToken)
{
var groupsCacheKey = CanonicalGroupsCacheKey(groups);
Expand All @@ -879,7 +944,8 @@ void TryCaptureDedupedFeatureFlagCalledEvent(
groups: groups,
sendFeatureFlags: false,
flags: null,
timestamp: null);
timestamp: null,
minimalFeatureFlagCalledEvent: minimalEvent);
return true;
});

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -1013,7 +1085,11 @@ public async Task<FeatureFlagEvaluations> 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)
Expand Down Expand Up @@ -1069,7 +1145,11 @@ public async Task<FeatureFlagEvaluations> 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);
}
}
}
Expand All @@ -1092,14 +1172,15 @@ 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,
Flag = flag,
Enabled = flag.IsEnabled,
CacheKeyValue = (string)flag,
LocallyEvaluated = locallyEvaluated,
MinimalFlagCalledEvents = minimalFlagCalledEvents,
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/PostHog/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -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<PostHog.Api.CapturedEvent!, PostHog.Api.CapturedEvent?>?
Expand Down
96 changes: 96 additions & 0 deletions tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PostHogClient>();

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/<key> 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<PostHogClient>();

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()
{
Expand Down
Loading
Loading