File tree Expand file tree Collapse file tree
src/PostHog.AspNetCore/FeatureManagement
tests/UnitTests.AspNetCore/FeatureManagement Expand file tree Collapse file tree Original file line number Diff line number Diff line change 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+ }
Original file line number Diff line number Diff 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 } ;
Original file line number Diff line number Diff 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
Original file line number Diff line number Diff line change @@ -67,6 +67,46 @@ public async Task ReturnsAllActiveFeatures()
6767 }
6868}
6969
70+ public class TheGetAllFeatureDefinitionsAsyncMethodFallback
71+ {
72+ // Stable sentinel distinct_id used by the enumeration fallback to /flags when no PersonalApiKey
73+ // is configured. Must stay stable so that PostHog doesn't spawn a phantom person on every poll
74+ // and so the /flags response is cacheable. See PostHog/posthog-dotnet#64.
75+ const string EnumerationSentinelDistinctId = "$feature_enumeration_sentinel" ;
76+
77+ [ Fact ]
78+ public async Task ReturnsFlagsFromFlagsEndpointWhenNoPersonalApiKey ( )
79+ {
80+ var container = new TestContainer ( sp =>
81+ {
82+ var builder = new PostHogConfigurationBuilder ( sp ) ;
83+ builder . UseFeatureManagement < FakePostHogFeatureFlagContextProvider > ( ) ;
84+ // No PersonalApiKey configured.
85+ } ) ;
86+ container . FakeHttpMessageHandler . AddFlagsResponse (
87+ requestPredicate : body =>
88+ body . TryGetValue ( "distinct_id" , out var id )
89+ && id is System . Text . Json . JsonElement el
90+ && el . GetString ( ) == EnumerationSentinelDistinctId ,
91+ responseBody : """
92+ {
93+ "featureFlags": {
94+ "beta-feature": true,
95+ "alpha-feature": false
96+ }
97+ }
98+ """
99+ ) ;
100+ var provider = container . Activate < PostHogFeatureDefinitionProvider > ( ) ;
101+
102+ var features = await provider . GetAllFeatureDefinitionsAsync ( ) . ToListAsync ( ) ;
103+
104+ Assert . Equal (
105+ new [ ] { "beta-feature" , "alpha-feature" } ,
106+ features . Select ( f => f . Name ) . OrderByDescending ( n => n ) ) ;
107+ }
108+ }
109+
70110public class TheGetFeatureDefinitionAsyncMethod
71111{
72112 [ Fact ]
Original file line number Diff line number Diff line change @@ -63,6 +63,45 @@ public async Task ReturnsAllActiveFeatures()
6363 }
6464}
6565
66+ public class TheGetFeatureNamesAsyncMethodFallback
67+ {
68+ // Stable sentinel distinct_id used by the enumeration fallback to /flags when no PersonalApiKey
69+ // is configured. See PostHog/posthog-dotnet#64.
70+ const string EnumerationSentinelDistinctId = "$feature_enumeration_sentinel" ;
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 ( ) == EnumerationSentinelDistinctId ,
86+ responseBody : """
87+ {
88+ "featureFlags": {
89+ "beta-feature": true,
90+ "alpha-feature": false
91+ }
92+ }
93+ """
94+ ) ;
95+ var featureManager = container . Activate < PostHogVariantFeatureManager > ( ) ;
96+
97+ var featureNames = await featureManager . GetFeatureNamesAsync ( ) . ToListAsync ( ) ;
98+
99+ Assert . Equal (
100+ new [ ] { "beta-feature" , "alpha-feature" } ,
101+ featureNames . OrderByDescending ( n => n ) ) ;
102+ }
103+ }
104+
66105public class TheIsEnabledAsyncMethod
67106{
68107 [ Fact ]
You can’t perform that action at this time.
0 commit comments