Skip to content

Commit d59d8f3

Browse files
fix(feature-management): enumerate flags without PersonalApiKey (#250)
Without a PersonalApiKey the local evaluator is null, which left PostHogVariantFeatureManager.GetFeatureNamesAsync and PostHogFeatureDefinitionProvider.GetAllFeatureDefinitionsAsync returning nothing — breaking the MSFT Feature Management definition-driven path for SDK consumers who use the remote /flags evaluation path only. Fall back to a single /flags poll using a stable sentinel distinct_id ($feature_enumeration_sentinel) and use the returned flag keys. The sentinel stays constant so the existing in-memory /flags cache (keyed by distinct_id) reuses the response and PostHog does not spawn a new phantom person per poll. Per-flag values for the sentinel are discarded — only the key set is consumed. GetAllFeatureFlagsAsync does not emit $feature_flag_called events, so no analytics noise. Local evaluation behavior is unchanged when a PersonalApiKey exists. Refs #64.
1 parent 9737231 commit d59d8f3

5 files changed

Lines changed: 130 additions & 6 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace PostHog.FeatureManagement;
2+
3+
/// <summary>
4+
/// Shared fallback used by <see cref="PostHogFeatureDefinitionProvider"/> and
5+
/// <see cref="PostHogVariantFeatureManager"/> to enumerate flag keys when no
6+
/// <see cref="Config.PostHogOptions.PersonalApiKey"/> is configured (no local evaluation source).
7+
/// </summary>
8+
/// <remarks>
9+
/// <para>
10+
/// Polls <c>/flags</c> with a stable sentinel <c>distinct_id</c> and returns the keys from the
11+
/// response. Per-flag values for the sentinel are discarded — only the set of keys is consumed.
12+
/// </para>
13+
/// <para>
14+
/// The sentinel id is intentionally stable: a random id would spawn a phantom person on every poll
15+
/// and defeat the in-memory <c>/flags</c> cache. See PostHog/posthog-dotnet#64 for the rationale.
16+
/// </para>
17+
/// </remarks>
18+
internal static class FeatureEnumerationFallback
19+
{
20+
/// <summary>
21+
/// Stable sentinel <c>distinct_id</c> used by the enumeration fallback. Stays constant so the
22+
/// SDK's in-memory <c>/flags</c> cache (keyed by distinct_id) reuses the response and PostHog
23+
/// doesn't create a new phantom person per poll.
24+
/// </summary>
25+
internal const string SentinelDistinctId = "$feature_enumeration_sentinel";
26+
27+
public static async Task<IReadOnlyCollection<string>> GetFeatureKeysAsync(
28+
IPostHogClient posthog,
29+
CancellationToken cancellationToken)
30+
{
31+
var flags = await posthog.GetAllFeatureFlagsAsync(
32+
distinctId: SentinelDistinctId,
33+
options: null,
34+
cancellationToken);
35+
// Order is not guaranteed by /flags; callers (Microsoft Feature Management) don't rely on it.
36+
return flags.Keys.ToList();
37+
}
38+
}

src/PostHog.AspNetCore/FeatureManagement/PostHogFeatureDefinitionProvider.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,25 @@ public async IAsyncEnumerable<FeatureDefinition> GetAllFeatureDefinitionsAsync()
3131
{
3232
foreach (var flag in localEvaluator.LocalEvaluationApiResult.Flags)
3333
{
34-
yield return CreateFeatureDefinition(flag);
34+
yield return CreateFeatureDefinition(flag.Key);
3535
}
36+
yield break;
37+
}
38+
39+
// Fallback: no PersonalApiKey means no local-evaluation flag list. Poll /flags with a stable
40+
// sentinel distinct_id and use the returned keys as the enumeration source. We only care about
41+
// keys; the values for the sentinel are discarded. See PostHog/posthog-dotnet#64.
42+
foreach (var key in await FeatureEnumerationFallback.GetFeatureKeysAsync(posthog, CancellationToken.None))
43+
{
44+
yield return CreateFeatureDefinition(key);
3645
}
3746
}
3847

39-
static FeatureDefinition CreateFeatureDefinition(LocalFeatureFlag flag)
48+
static FeatureDefinition CreateFeatureDefinition(string key)
4049
{
4150
return new FeatureDefinition
4251
{
43-
Name = flag.Key,
52+
Name = key,
4453
EnabledFor = [new FeatureFilterConfiguration { Name = "PostHog" }],
4554
RequirementType = RequirementType.Any
4655
};

src/PostHog.AspNetCore/FeatureManagement/PostHogVariantFeatureManager.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,20 @@ public async IAsyncEnumerable<string> GetFeatureNamesAsync(
2323
[EnumeratorCancellation] CancellationToken cancellationToken = new())
2424
{
2525
var localEvaluator = await posthog.GetLocalEvaluatorAsync(cancellationToken);
26-
if (localEvaluator is null)
26+
if (localEvaluator is not null)
2727
{
28+
foreach (var flag in localEvaluator.LocalEvaluationApiResult.Flags)
29+
{
30+
yield return flag.Key;
31+
}
2832
yield break;
2933
}
30-
foreach (var flag in localEvaluator.LocalEvaluationApiResult.Flags)
34+
35+
// Fallback: no PersonalApiKey means no local-evaluation flag list. Poll /flags with a stable
36+
// sentinel distinct_id and yield the returned keys. See PostHog/posthog-dotnet#64.
37+
foreach (var key in await FeatureEnumerationFallback.GetFeatureKeysAsync(posthog, cancellationToken))
3138
{
32-
yield return flag.Key;
39+
yield return key;
3340
}
3441
}
3542

tests/UnitTests.AspNetCore/FeatureManagement/PostHogFeatureDefinitionProviderTests.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,41 @@ public async Task ReturnsAllActiveFeatures()
6767
}
6868
}
6969

70+
public class TheGetAllFeatureDefinitionsAsyncMethodFallback
71+
{
72+
[Fact]
73+
public async Task ReturnsFlagsFromFlagsEndpointWhenNoPersonalApiKey()
74+
{
75+
var container = new TestContainer(sp =>
76+
{
77+
var builder = new PostHogConfigurationBuilder(sp);
78+
builder.UseFeatureManagement<FakePostHogFeatureFlagContextProvider>();
79+
// No PersonalApiKey configured.
80+
});
81+
container.FakeHttpMessageHandler.AddFlagsResponse(
82+
requestPredicate: body =>
83+
body.TryGetValue("distinct_id", out var id)
84+
&& id is System.Text.Json.JsonElement el
85+
&& el.GetString() == FeatureEnumerationFallback.SentinelDistinctId,
86+
responseBody: """
87+
{
88+
"featureFlags": {
89+
"beta-feature": true,
90+
"alpha-feature": false
91+
}
92+
}
93+
"""
94+
);
95+
var provider = container.Activate<PostHogFeatureDefinitionProvider>();
96+
97+
var features = await provider.GetAllFeatureDefinitionsAsync().ToListAsync();
98+
99+
Assert.Equal(
100+
new[] { "beta-feature", "alpha-feature" },
101+
features.Select(f => f.Name).OrderByDescending(n => n));
102+
}
103+
}
104+
70105
public class TheGetFeatureDefinitionAsyncMethod
71106
{
72107
[Fact]

tests/UnitTests.AspNetCore/FeatureManagement/PostHogVariantFeatureManagerTests.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,41 @@ public async Task ReturnsAllActiveFeatures()
6363
}
6464
}
6565

66+
public class TheGetFeatureNamesAsyncMethodFallback
67+
{
68+
[Fact]
69+
public async Task ReturnsFlagsFromFlagsEndpointWhenNoPersonalApiKey()
70+
{
71+
var container = new TestContainer(sp =>
72+
{
73+
var builder = new PostHogConfigurationBuilder(sp);
74+
builder.UseFeatureManagement<FakePostHogFeatureFlagContextProvider>();
75+
// No PersonalApiKey configured.
76+
});
77+
container.FakeHttpMessageHandler.AddFlagsResponse(
78+
requestPredicate: body =>
79+
body.TryGetValue("distinct_id", out var id)
80+
&& id is System.Text.Json.JsonElement el
81+
&& el.GetString() == FeatureEnumerationFallback.SentinelDistinctId,
82+
responseBody: """
83+
{
84+
"featureFlags": {
85+
"beta-feature": true,
86+
"alpha-feature": false
87+
}
88+
}
89+
"""
90+
);
91+
var featureManager = container.Activate<PostHogVariantFeatureManager>();
92+
93+
var featureNames = await featureManager.GetFeatureNamesAsync().ToListAsync();
94+
95+
Assert.Equal(
96+
new[] { "beta-feature", "alpha-feature" },
97+
featureNames.OrderByDescending(n => n));
98+
}
99+
}
100+
66101
public class TheIsEnabledAsyncMethod
67102
{
68103
[Fact]

0 commit comments

Comments
 (0)