diff --git a/.changeset/few-flags-dance.md b/.changeset/few-flags-dance.md new file mode 100644 index 00000000..e12e9b64 --- /dev/null +++ b/.changeset/few-flags-dance.md @@ -0,0 +1,6 @@ +--- +"PostHog": minor +"PostHog.AspNetCore": minor +--- + +Add feature flag evaluation contexts via `PostHogOptions.EvaluationContexts`. `/flags` requests now send `evaluation_contexts` when configured. diff --git a/README.md b/README.md index 837f005e..bbb852b0 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,22 @@ if (await posthog.IsFeatureEnabledAsync( > [!NOTE] > Specifying `PersonProperties` and `GroupProperties` is necessary when using local evaluation of feature flags. +#### Evaluation contexts + +You can configure SDK-level feature flag evaluation contexts to only evaluate flags intended for this application, +platform, or product area. + +```csharp +services.AddPostHog(options => +{ + options.ProjectToken = "YOUR_PROJECT_TOKEN"; + options.EvaluationContexts = ["main-app", "api", "backend"]; +}); +``` + +Flags without evaluation contexts continue to evaluate for all SDKs. Flags with contexts evaluate only when at least +one configured context matches. + ### Get a single Feature Flag Some feature flags may have associated payloads. diff --git a/src/PostHog.AspNetCore/README.md b/src/PostHog.AspNetCore/README.md index acd09bad..dedaff1a 100644 --- a/src/PostHog.AspNetCore/README.md +++ b/src/PostHog.AspNetCore/README.md @@ -253,6 +253,22 @@ options: new AllFeatureFlagsOptions }); ``` +### Evaluation contexts + +Configure SDK-level feature flag evaluation contexts to only evaluate flags intended for this application, +platform, or product area. + +```csharp +builder.Services.AddPostHog(options => +{ + options.ProjectToken = "YOUR_PROJECT_TOKEN"; + options.EvaluationContexts = ["main-app", "api", "backend"]; +}); +``` + +Flags without evaluation contexts continue to evaluate for all SDKs. Flags with contexts evaluate only when at least +one configured context matches. + ## Feature Management `PostHog.AspNetCore` supports .NET Feature Management. This allows you to use the <feature /> tag helper and diff --git a/src/PostHog/Api/PostHogApiClient.cs b/src/PostHog/Api/PostHogApiClient.cs index e7b0c1f0..1f1a5e22 100644 --- a/src/PostHog/Api/PostHogApiClient.cs +++ b/src/PostHog/Api/PostHogApiClient.cs @@ -133,6 +133,11 @@ public async Task SendEventAsync( payload["flag_keys_to_evaluate"] = flagKeysToEvaluate; } + if (_options.Value.EvaluationContexts is { Count: > 0 } evaluationContexts) + { + payload["evaluation_contexts"] = evaluationContexts; + } + groupProperties?.AddToPayload(payload); PrepareAndMutatePayload(payload); diff --git a/src/PostHog/Config/PostHogOptions.cs b/src/PostHog/Config/PostHogOptions.cs index feca6a7e..192504b0 100644 --- a/src/PostHog/Config/PostHogOptions.cs +++ b/src/PostHog/Config/PostHogOptions.cs @@ -58,6 +58,15 @@ public string? ProjectApiKey /// public string? PersonalApiKey { get; set; } + /// + /// Evaluation contexts for feature flags. + /// + /// + /// When set, only feature flags with no evaluation contexts or at least one matching context will be evaluated + /// for this SDK instance. Use this to isolate flags by application, platform, or product area. + /// + public IReadOnlyList? EvaluationContexts { get; set; } + /// /// Whether this client is disabled and should no-op instead of sending data to PostHog. (Default: false) /// diff --git a/tests/UnitTests/Config/RegistrationTests.cs b/tests/UnitTests/Config/RegistrationTests.cs index 8fa96ce7..6dbc41c2 100644 --- a/tests/UnitTests/Config/RegistrationTests.cs +++ b/tests/UnitTests/Config/RegistrationTests.cs @@ -48,6 +48,8 @@ public void CanReadConfiguration() ["PostHogTest:FeatureFlagPollInterval"] = "00:00:10", ["PostHogTest:FlushAt"] = "10", ["PostHogTest:MaxBatchSize"] = "99", + ["PostHogTest:EvaluationContexts:0"] = "main-app", + ["PostHogTest:EvaluationContexts:1"] = "api", ["PostHogTest:SuperProperties:Castle"] = "Winterfell", ["PostHogTest:SuperProperties:Family"] = "Starks" }) @@ -73,6 +75,7 @@ public void CanReadConfiguration() Assert.Equal(TimeSpan.FromSeconds(10), options.FeatureFlagPollInterval); Assert.Equal(10, options.FlushAt); Assert.Equal(99, options.MaxBatchSize); + Assert.Equal(new[] { "main-app", "api" }, options.EvaluationContexts); Assert.Equal(new() { ["Castle"] = "Winterfell", diff --git a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs index b82ce0a8..ae7fbdee 100644 --- a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs @@ -79,6 +79,46 @@ await client.EvaluateFlagsAsync( Assert.Equal(new[] { "flag-a", "flag-b" }, flagKeys); } + public static IEnumerable EvaluationContextsRequestBodyCases() + { + yield return [new[] { "production", "backend" }, new[] { "production", "backend" }]; + yield return [Array.Empty(), null]; + yield return [null, null]; + } + + [Theory] + [MemberData(nameof(EvaluationContextsRequestBodyCases))] + public async Task SendsEvaluationContextsToFlagsRequestBodyWhenConfigured( + string[]? configuredEvaluationContexts, + string[]? expectedEvaluationContexts) + { + var container = new TestContainer(services => services.Configure(options => + { + options.EvaluationContexts = configuredEvaluationContexts; + })); + var flagsHandler = container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {"flag-a": true}}"""); + var client = container.Activate(); + + await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + + var request = flagsHandler.ReceivedRequests.Single(); + var body = await request.Content!.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + + if (expectedEvaluationContexts is null) + { + Assert.False(doc.RootElement.TryGetProperty("evaluation_contexts", out _)); + } + else + { + var evaluationContexts = doc.RootElement.GetProperty("evaluation_contexts") + .EnumerateArray() + .Select(e => e.GetString() ?? string.Empty) + .ToArray(); + Assert.Equal(expectedEvaluationContexts, evaluationContexts); + } + } + [Fact] public async Task OnlyEvaluateLocallyDoesNotHitRemote() {