diff --git a/.changeset/lucky-geese-flags.md b/.changeset/lucky-geese-flags.md new file mode 100644 index 00000000..2d7a0848 --- /dev/null +++ b/.changeset/lucky-geese-flags.md @@ -0,0 +1,5 @@ +--- +"PostHog": patch +--- + +Add a feature flag request option for disabling GeoIP enrichment. diff --git a/.github/workflows/sdk-compliance.yml b/.github/workflows/sdk-compliance.yml index 1c78c0eb..22853555 100644 --- a/.github/workflows/sdk-compliance.yml +++ b/.github/workflows/sdk-compliance.yml @@ -14,8 +14,8 @@ on: jobs: compliance: name: PostHog SDK compliance tests - uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@39d05346c4638d24f94e591ab89c9c6fcdb52d6b + uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@be8b8d5a3f94a249659844e94832e874f049c1e4 with: adapter-dockerfile: "sdk_compliance_adapter/Dockerfile" adapter-context: "." - test-harness-version: "latest" + test-harness-version: "0.8.0" diff --git a/sdk_compliance_adapter/CONTRIBUTING.md b/sdk_compliance_adapter/CONTRIBUTING.md index 3b052e84..8932b0f4 100644 --- a/sdk_compliance_adapter/CONTRIBUTING.md +++ b/sdk_compliance_adapter/CONTRIBUTING.md @@ -35,7 +35,7 @@ docker run -d --name sdk-adapter --network test-network -p 8080:8080 posthog-dot docker run --rm \ --name test-harness \ --network test-network \ - ghcr.io/posthog/sdk-test-harness:latest \ + ghcr.io/posthog/sdk-test-harness:0.8.0 \ run --adapter-url http://sdk-adapter:8080 --mock-url http://test-harness:8081 # Cleanup diff --git a/sdk_compliance_adapter/Program.cs b/sdk_compliance_adapter/Program.cs index d07b883c..1167bf12 100644 --- a/sdk_compliance_adapter/Program.cs +++ b/sdk_compliance_adapter/Program.cs @@ -5,6 +5,8 @@ using PostHog; using PostHog.Versioning; +const int StaleEventDrainDelayMs = 100; + var builder = WebApplication.CreateBuilder(args); // Configure JSON options for consistent serialization @@ -102,6 +104,58 @@ return Results.Ok(new { success = true }); }); +app.MapPost("/get_feature_flag", async (FeatureFlagRequest request) => +{ + if (state.Client is null) + { + return Results.BadRequest(new { error = "SDK not initialized" }); + } + + if (string.IsNullOrEmpty(request.Key) || string.IsNullOrEmpty(request.DistinctId)) + { + return Results.BadRequest(new { error = "key and distinct_id are required" }); + } + + try + { + var options = new FeatureFlagOptions + { + PersonProperties = request.PersonProperties, + Groups = ToGroupCollection(request.Groups, request.GroupProperties), + FlagKeysToEvaluate = [request.Key], + OnlyEvaluateLocally = request.ForceRemote == false, + DisableGeoIp = request.DisableGeoIp ?? false + }; + +#pragma warning disable CS0618 + var flag = await state.Client.GetFeatureFlagAsync( + request.Key, + request.DistinctId, + options, + CancellationToken.None); +#pragma warning restore CS0618 + + // Feature-flag evaluation captures a documented $feature_flag_called side-effect event. + // Flush it in the same adapter action so a later /reset does not send stale events into + // the next harness test's freshly-reset mock server. Keep a named drain delay aligned with + // the /flush endpoint so async send completion timing is explicit and tunable. + await state.Client.FlushAsync(); + await Task.Delay(StaleEventDrainDelayMs); + + return Results.Ok(new + { + success = true, + value = flag?.VariantKey ?? (object?)(flag?.IsEnabled ?? false) + }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Feature flag error: {ex}"); + state.RecordError(ex.Message); + return Results.StatusCode(500); + } +}); + app.MapPost("/flush", async () => { if (state.Client is null) @@ -113,8 +167,8 @@ { await state.Client.FlushAsync(); - // Wait a bit for any pending requests to complete - await Task.Delay(100); + // Wait a bit for any pending requests to complete. + await Task.Delay(StaleEventDrainDelayMs); } catch (Exception ex) { @@ -143,6 +197,48 @@ return Results.Ok(new { success = true }); }); +GroupCollection? ToGroupCollection( + Dictionary? groups, + Dictionary>? groupProperties) +{ + if ((groups is null || groups.Count == 0) && (groupProperties is null || groupProperties.Count == 0)) + { + return null; + } + + var collection = new GroupCollection(); + if (groups is null) + { + return collection; + } + + foreach (var (groupType, groupKeyValue) in groups) + { + var groupKey = ToStringValue(groupKeyValue); + if (groupKey is null) + { + continue; + } + + var properties = groupProperties is not null && groupProperties.TryGetValue(groupType, out var props) + ? props + : []; + collection.Add(new Group(groupType, groupKey, properties)); + } + + return collection; +} + +static string? ToStringValue(object? value) => value switch +{ + null => null, + string s => s, + JsonElement { ValueKind: JsonValueKind.Null } => null, + JsonElement { ValueKind: JsonValueKind.String } json => json.GetString(), + JsonElement json => json.ToString(), + _ => value.ToString() +}; + app.Run(); // --- Models --- @@ -170,6 +266,16 @@ record CaptureRequest( [property: JsonPropertyName("timestamp")] string? Timestamp = null ); +record FeatureFlagRequest( + [property: JsonPropertyName("key")] string Key, + [property: JsonPropertyName("distinct_id")] string DistinctId, + [property: JsonPropertyName("person_properties")] Dictionary? PersonProperties = null, + [property: JsonPropertyName("groups")] Dictionary? Groups = null, + [property: JsonPropertyName("group_properties")] Dictionary>? GroupProperties = null, + [property: JsonPropertyName("disable_geoip")] bool? DisableGeoIp = null, + [property: JsonPropertyName("force_remote")] bool? ForceRemote = null +); + record StateResponse( [property: JsonPropertyName("pending_events")] int PendingEvents, [property: JsonPropertyName("total_events_captured")] int TotalEventsCaptured, diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml index bea2f812..2e11ed12 100644 --- a/sdk_compliance_adapter/docker-compose.yml +++ b/sdk_compliance_adapter/docker-compose.yml @@ -4,14 +4,12 @@ services: build: context: .. dockerfile: sdk_compliance_adapter/Dockerfile - ports: - - "8080:8080" networks: - test-network # Test harness test-harness: - image: ghcr.io/posthog/sdk-test-harness:latest + image: ghcr.io/posthog/sdk-test-harness:0.8.0 command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081"] networks: - test-network diff --git a/src/PostHog/Api/PostHogApiClient.cs b/src/PostHog/Api/PostHogApiClient.cs index d2ba17fa..f3c0d35b 100644 --- a/src/PostHog/Api/PostHogApiClient.cs +++ b/src/PostHog/Api/PostHogApiClient.cs @@ -107,6 +107,7 @@ public async Task SendEventAsync( /// Optional: What person properties are known. Used to compute flags locally, if personalApiKey is present. Not needed if using remote evaluation, but can be used to override remote values for the purposes of feature flag evaluation. /// Optional: What group properties are known. Used to compute flags locally, if personalApiKey is present. Not needed if using remote evaluation, but can be used to override remote values for the purposes of feature flag evaluation. /// The set of flag keys to evaluate. If empty, this returns all flags. + /// Whether to disable GeoIP enrichment for the request. /// The cancellation token that can be used to cancel the operation. /// A . public async Task GetFeatureFlagsAsync( @@ -114,18 +115,29 @@ public async Task SendEventAsync( Dictionary? personProperties, GroupCollection? groupProperties, IReadOnlyList? flagKeysToEvaluate, + bool disableGeoIp, CancellationToken cancellationToken) { var endpointUrl = new Uri(HostUrl, "flags/?v=2"); var payload = new Dictionary { - ["distinct_id"] = distinctUserId + ["api_key"] = ProjectToken, + ["distinct_id"] = distinctUserId, + ["groups"] = new Dictionary(), + ["group_properties"] = new Dictionary>(), + ["geoip_disable"] = disableGeoIp }; if (personProperties is { Count: > 0 }) { - payload["person_properties"] = personProperties; + var mergedPersonProperties = new Dictionary(personProperties); + if (!mergedPersonProperties.ContainsKey("distinct_id")) + { + mergedPersonProperties["distinct_id"] = distinctUserId; + } + + payload["person_properties"] = mergedPersonProperties; } if (flagKeysToEvaluate is { Count: > 0 }) diff --git a/src/PostHog/Features/FeatureFlagOptions.cs b/src/PostHog/Features/FeatureFlagOptions.cs index 5e2a6667..c3bf5d4d 100644 --- a/src/PostHog/Features/FeatureFlagOptions.cs +++ b/src/PostHog/Features/FeatureFlagOptions.cs @@ -49,4 +49,9 @@ public class AllFeatureFlagsOptions /// The set of flag keys to evaluate in this request. If not specified, all flags are evaluated. /// public IReadOnlyList FlagKeysToEvaluate { get; init; } = []; + + /// + /// Whether to disable GeoIP enrichment for the feature flag request. Defaults to false. + /// + public bool DisableGeoIp { get; init; } } \ No newline at end of file diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index ca25739f..a14b9a60 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -1146,6 +1146,7 @@ async Task FetchFlagsAsync(string distId, CancellationToken ctx) options?.PersonProperties, options?.Groups, options?.FlagKeysToEvaluate, + options?.DisableGeoIp ?? false, ctx); return results.ToFlagsResult(); } diff --git a/src/PostHog/PublicAPI.Unshipped.txt b/src/PostHog/PublicAPI.Unshipped.txt index 56331f82..04826151 100644 --- a/src/PostHog/PublicAPI.Unshipped.txt +++ b/src/PostHog/PublicAPI.Unshipped.txt @@ -1,3 +1,5 @@ #nullable enable +PostHog.AllFeatureFlagsOptions.DisableGeoIp.get -> bool +PostHog.AllFeatureFlagsOptions.DisableGeoIp.init -> void PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.get -> int PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.set -> void diff --git a/tests/UnitTests/Features/FeatureFlagsTests.cs b/tests/UnitTests/Features/FeatureFlagsTests.cs index fb403c59..8588a0dd 100644 --- a/tests/UnitTests/Features/FeatureFlagsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagsTests.cs @@ -2371,8 +2371,10 @@ public async Task MultivariateFeatureFlagPayloads() JsonAssert.Equal("""{"a":"json"}""", result?.Payload); } - [Fact] - public async Task CallsDecideWithFlagKeyToEvaluate() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CallsDecideWithFlagKeyToEvaluate(bool disableGeoIp) { var container = new TestContainer(); var handler = container.FakeHttpMessageHandler.AddFlagsResponse( @@ -2382,21 +2384,58 @@ public async Task CallsDecideWithFlagKeyToEvaluate() ); var client = container.Activate(); - var result = await client.GetFeatureFlagAsync("beta-feature", "some-distinct-id"); + var result = await client.GetFeatureFlagAsync( + "beta-feature", + "some-distinct-id", + new FeatureFlagOptions + { + DisableGeoIp = disableGeoIp, + FlagKeysToEvaluate = ["beta-feature"] + }); Assert.NotNull(result); Assert.Equal(new FeatureFlag { Key = "beta-feature", VariantKey = "alakazam" }, result); - var receivedBody = handler.GetReceivedRequestBody(true); - Assert.StartsWith( + using var document = JsonDocument.Parse(handler.GetReceivedRequestBody(indented: false)); + var root = document.RootElement; + Assert.Equal("fake-project-token", root.GetProperty("api_key").GetString()); + Assert.Equal("some-distinct-id", root.GetProperty("distinct_id").GetString()); + Assert.Empty(root.GetProperty("groups").EnumerateObject()); + Assert.Empty(root.GetProperty("group_properties").EnumerateObject()); + Assert.Equal(disableGeoIp, root.GetProperty("geoip_disable").GetBoolean()); + var flagKey = Assert.Single(root.GetProperty("flag_keys_to_evaluate").EnumerateArray()); + Assert.Equal("beta-feature", flagKey.GetString()); + } + + [Fact] + public async Task PreservesExplicitPersonPropertiesDistinctId() + { + var container = new TestContainer(); + var handler = container.FakeHttpMessageHandler.AddFlagsResponse( + """ + {"featureFlags": {"beta-feature": true}} """ + ); + var client = container.Activate(); + + var result = await client.GetFeatureFlagAsync( + "beta-feature", + "top-level-distinct-id", + new FeatureFlagOptions { - "distinct_id": "some-distinct-id", - "flag_keys_to_evaluate": [ - "beta-feature" - ], - """, - receivedBody, - StringComparison.Ordinal); + PersonProperties = new() + { + ["distinct_id"] = "person-property-distinct-id", + ["email"] = "test@posthog.com" + } + }); + + Assert.True(result); + using var document = JsonDocument.Parse(handler.GetReceivedRequestBody(indented: false)); + var root = document.RootElement; + Assert.Equal("top-level-distinct-id", root.GetProperty("distinct_id").GetString()); + var personProperties = root.GetProperty("person_properties"); + Assert.Equal("person-property-distinct-id", personProperties.GetProperty("distinct_id").GetString()); + Assert.Equal("test@posthog.com", personProperties.GetProperty("email").GetString()); } [Fact]