Skip to content

Commit a329a13

Browse files
authored
feat(flags): add $feature_flag_has_experiment to $feature_flag_called events (#261)
* Add $feature_flag_has_experiment to $feature_flag_called events * Omit $feature_flag_has_experiment when the server does not report it
1 parent 7c00896 commit a329a13

7 files changed

Lines changed: 153 additions & 6 deletions

File tree

.changeset/tall-cows-attend.md

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+
Add a `$feature_flag_has_experiment` boolean property to `$feature_flag_called` events when the server reports whether the flag is linked to an experiment. The property is omitted when the server does not report it (older deployments and legacy response formats).

src/PostHog/Api/FeatureFlagWithMetadata.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,11 @@ internal record FeatureFlagMetadata
8484
/// A description of the feature flag.
8585
/// </summary>
8686
public string? Description { get; init; }
87+
88+
/// <summary>
89+
/// Whether the feature flag is linked to an experiment. <c>null</c> when the server does not
90+
/// report the field (older deployments).
91+
/// </summary>
92+
[JsonPropertyName("has_experiment")]
93+
public bool? HasExperiment { get; init; }
8794
}

src/PostHog/Api/LocalEvaluationApiResult.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ internal record LocalFeatureFlag
107107
/// </summary>
108108
[JsonPropertyName("ensure_experience_continuity")]
109109
public bool EnsureExperienceContinuity { get; init; }
110+
111+
/// <summary>
112+
/// Whether the feature flag is linked to an experiment. <c>null</c> when the server does not
113+
/// report the field (older deployments).
114+
/// </summary>
115+
[JsonPropertyName("has_experiment")]
116+
public bool? HasExperiment { get; init; }
110117
}
111118

112119
/// <summary>

src/PostHog/Features/FeatureFlag.cs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ public record FeatureFlag
3131
/// </summary>
3232
public bool IsEnabled { get; init; } = true;
3333

34+
/// <summary>
35+
/// Whether this feature flag is linked to an experiment, as reported by the server.
36+
/// <c>null</c> when the server does not report it (older deployments).
37+
/// </summary>
38+
public bool? HasExperiment { get; init; }
39+
3440
/// <summary>
3541
/// Creates a <see cref="FeatureFlag"/> instance from the <c>/flags</c> endpoint response. Since payloads are
3642
/// already calculated, we can look them up by the feature key.
@@ -44,10 +50,11 @@ internal static FeatureFlag CreateFromFlagsApi(
4450
FlagsApiResult apiResult)
4551
{
4652
var payload = NotNull(apiResult).FeatureFlagPayloads?.GetValueOrDefault(key);
53+
var flag = apiResult.Flags?.GetValueOrDefault(key);
4754

48-
var featureFlag = apiResult.Flags is not null && apiResult.Flags.TryGetValue(key, out var flag)
49-
&& flag.Metadata is { Id: { } id, Version: { } version }
50-
&& flag.Reason?.Description is { } reason
55+
var featureFlag = flag is not null
56+
&& flag.Metadata is { Id: { } id, Version: { } version }
57+
&& flag.Reason?.Description is { } reason
5158
? new FeatureFlagWithMetadata
5259
{
5360
Key = flag.Key,
@@ -65,7 +72,8 @@ internal static FeatureFlag CreateFromFlagsApi(
6572
{
6673
IsEnabled = value.IsString ? value.StringValue is not null : value.Value,
6774
VariantKey = value.StringValue,
68-
Payload = payload is null ? null : JsonDocument.Parse(payload)
75+
Payload = payload is null ? null : JsonDocument.Parse(payload),
76+
HasExperiment = flag?.Metadata?.HasExperiment
6977
};
7078
}
7179

@@ -90,7 +98,8 @@ internal static FeatureFlag CreateFromLocalEvaluation(
9098
Key = key,
9199
IsEnabled = value.IsString ? value.StringValue is not null : value.Value,
92100
VariantKey = value.StringValue,
93-
Payload = payloadJsonString is null ? null : JsonDocument.Parse(payloadJsonString)
101+
Payload = payloadJsonString is null ? null : JsonDocument.Parse(payloadJsonString),
102+
HasExperiment = localFeatureFlag.HasExperiment
94103
};
95104
}
96105

@@ -104,13 +113,14 @@ other is not null
104113
&& Key == other.Key
105114
&& IsEnabled == other.IsEnabled
106115
&& VariantKey == other.VariantKey
116+
&& HasExperiment == other.HasExperiment
107117
&& JsonEqual(Payload, other.Payload);
108118

109119
/// <summary>
110120
/// Serves as the default hash function.
111121
/// </summary>
112122
/// <returns>A hash code for the current <see cref="FeatureFlag"/>.</returns>
113-
public override int GetHashCode() => HashCode.Combine(Key, IsEnabled, VariantKey, Payload);
123+
public override int GetHashCode() => HashCode.Combine(Key, IsEnabled, VariantKey, HasExperiment, Payload);
114124

115125
static bool JsonEqual(JsonDocument? source, JsonDocument? comparand) =>
116126
JsonNode.DeepEquals(ToJsonNode(source), ToJsonNode(comparand));

src/PostHog/PostHogClient.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,13 @@ static Dictionary<string, object> BuildFeatureFlagCalledProperties(
811811
["locally_evaluated"] = locallyEvaluated,
812812
[$"$feature/{featureKey}"] = flag.ToResponseObject()
813813
};
814+
815+
// Tri-state: only sent when the server explicitly reported has_experiment; omitted when
816+
// unknown (older deployments, legacy response formats, or missing flags).
817+
if (flag?.HasExperiment is { } hasExperiment)
818+
{
819+
properties["$feature_flag_has_experiment"] = hasExperiment;
820+
}
814821
if (locallyEvaluated)
815822
{
816823
properties["$feature_flag_reason"] = "Evaluated locally";

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.Features.FeatureFlag.HasExperiment.get -> bool?
5+
PostHog.Features.FeatureFlag.HasExperiment.init -> void
46
PostHog.PostHogOptions.BeforeSend.get -> System.Func<PostHog.Api.CapturedEvent!, PostHog.Api.CapturedEvent?>?
57
PostHog.PostHogOptions.BeforeSend.set -> void
68
PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.get -> int

tests/UnitTests/Features/FeatureFlagsTests.cs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,115 @@ public async Task CapturesFeatureFlagCalledEventWithAdditionalMetadataIdWhenPres
748748
"""
749749
, received);
750750
}
751+
752+
[Theory]
753+
[InlineData("\"has_experiment\": true,", true)]
754+
[InlineData("\"has_experiment\": false,", false)]
755+
[InlineData("", null)] // Older deployments don't report the field, so the property is omitted.
756+
public async Task CapturesFeatureFlagCalledEventWithHasExperimentFromFlagsMetadata(
757+
string hasExperimentJson,
758+
bool? expected)
759+
{
760+
var container = new TestContainer();
761+
var messageHandler = container.FakeHttpMessageHandler;
762+
messageHandler.AddFlagsResponse(
763+
$$"""
764+
{
765+
"flags": {
766+
"flag-key": {
767+
"key": "flag-key",
768+
"enabled": true,
769+
"variant": null,
770+
"reason": {
771+
"code": "condition_match",
772+
"description": "Matched conditions set 1",
773+
"condition_index": 0
774+
},
775+
"metadata": {
776+
"id": 1,
777+
"version": 2,
778+
{{hasExperimentJson}}
779+
"description": "A flag"
780+
}
781+
}
782+
}
783+
}
784+
"""
785+
);
786+
var captureRequestHandler = messageHandler.AddBatchResponse();
787+
var client = container.Activate<PostHogClient>();
788+
789+
Assert.True(await client.IsFeatureEnabledAsync("flag-key", "a-distinct-id"));
790+
791+
await client.FlushAsync();
792+
using var document = JsonDocument.Parse(captureRequestHandler.GetReceivedRequestBody(indented: false));
793+
var properties = document.RootElement.GetProperty("batch")
794+
.EnumerateArray()
795+
.Single()
796+
.GetProperty("properties");
797+
if (expected is { } expectedValue)
798+
{
799+
Assert.Equal(expectedValue, properties.GetProperty("$feature_flag_has_experiment").GetBoolean());
800+
}
801+
else
802+
{
803+
Assert.False(properties.TryGetProperty("$feature_flag_has_experiment", out _));
804+
}
805+
}
806+
807+
[Theory]
808+
[InlineData("\"has_experiment\": true,", true)]
809+
[InlineData("\"has_experiment\": false,", false)]
810+
[InlineData("", null)] // Older deployments don't report the field, so the property is omitted.
811+
public async Task CapturesFeatureFlagCalledEventWithHasExperimentFromLocalEvaluation(
812+
string hasExperimentJson,
813+
bool? expected)
814+
{
815+
var container = new TestContainer(personalApiKey: "fake-personal-api-key");
816+
var messageHandler = container.FakeHttpMessageHandler;
817+
messageHandler.AddLocalEvaluationResponse(
818+
$$"""
819+
{
820+
"flags": [
821+
{
822+
"id": 1,
823+
"key": "flag-key",
824+
"active": true,
825+
{{hasExperimentJson}}
826+
"filters": {
827+
"groups": [
828+
{
829+
"properties": [],
830+
"rollout_percentage": 100
831+
}
832+
]
833+
}
834+
}
835+
]
836+
}
837+
"""
838+
);
839+
var captureRequestHandler = messageHandler.AddBatchResponse();
840+
var client = container.Activate<PostHogClient>();
841+
842+
Assert.True(await client.IsFeatureEnabledAsync("flag-key", "a-distinct-id"));
843+
844+
await client.FlushAsync();
845+
using var document = JsonDocument.Parse(captureRequestHandler.GetReceivedRequestBody(indented: false));
846+
var properties = document.RootElement.GetProperty("batch")
847+
.EnumerateArray()
848+
.Single()
849+
.GetProperty("properties");
850+
if (expected is { } expectedValue)
851+
{
852+
Assert.Equal(expectedValue, properties.GetProperty("$feature_flag_has_experiment").GetBoolean());
853+
}
854+
else
855+
{
856+
Assert.False(properties.TryGetProperty("$feature_flag_has_experiment", out _));
857+
}
858+
Assert.True(properties.GetProperty("locally_evaluated").GetBoolean());
859+
}
751860
}
752861

753862
public class TheGetFeatureFlagAsyncMethod

0 commit comments

Comments
 (0)