Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lucky-geese-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"PostHog": patch
---

Add a feature flag request option for disabling GeoIP enrichment.
4 changes: 2 additions & 2 deletions .github/workflows/sdk-compliance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion sdk_compliance_adapter/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 108 additions & 2 deletions sdk_compliance_adapter/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
using PostHog;
using PostHog.Versioning;

const int StaleEventDrainDelayMs = 100;

var builder = WebApplication.CreateBuilder(args);

// Configure JSON options for consistent serialization
Expand Down Expand Up @@ -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)
Expand All @@ -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)
{
Expand Down Expand Up @@ -143,6 +197,48 @@
return Results.Ok(new { success = true });
});

GroupCollection? ToGroupCollection(
Dictionary<string, object?>? groups,
Dictionary<string, Dictionary<string, object?>>? 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 ---
Expand Down Expand Up @@ -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<string, object?>? PersonProperties = null,
[property: JsonPropertyName("groups")] Dictionary<string, object?>? Groups = null,
[property: JsonPropertyName("group_properties")] Dictionary<string, Dictionary<string, object?>>? 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,
Expand Down
4 changes: 1 addition & 3 deletions sdk_compliance_adapter/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions src/PostHog/Api/PostHogApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,25 +107,37 @@ public async Task<ApiResult> SendEventAsync(
/// <param name="personProperties">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.</param>
/// <param name="groupProperties">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.</param>
/// <param name="flagKeysToEvaluate">The set of flag keys to evaluate. If empty, this returns all flags.</param>
/// <param name="disableGeoIp">Whether to disable GeoIP enrichment for the request.</param>
/// <param name="cancellationToken">The cancellation token that can be used to cancel the operation.</param>
/// <returns>A <see cref="FlagsApiResult"/>.</returns>
public async Task<FlagsApiResult?> GetFeatureFlagsAsync(
string distinctUserId,
Dictionary<string, object?>? personProperties,
GroupCollection? groupProperties,
IReadOnlyList<string>? flagKeysToEvaluate,
bool disableGeoIp,
CancellationToken cancellationToken)
{
var endpointUrl = new Uri(HostUrl, "flags/?v=2");

var payload = new Dictionary<string, object>
{
["distinct_id"] = distinctUserId
["api_key"] = ProjectToken,
["distinct_id"] = distinctUserId,
["groups"] = new Dictionary<string, string>(),
["group_properties"] = new Dictionary<string, Dictionary<string, object?>>(),
["geoip_disable"] = disableGeoIp
};

if (personProperties is { Count: > 0 })
{
payload["person_properties"] = personProperties;
var mergedPersonProperties = new Dictionary<string, object?>(personProperties);
if (!mergedPersonProperties.ContainsKey("distinct_id"))
{
mergedPersonProperties["distinct_id"] = distinctUserId;
}

payload["person_properties"] = mergedPersonProperties;
}

if (flagKeysToEvaluate is { Count: > 0 })
Expand Down
5 changes: 5 additions & 0 deletions src/PostHog/Features/FeatureFlagOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,9 @@ public class AllFeatureFlagsOptions
/// The set of flag keys to evaluate in this request. If not specified, all flags are evaluated.
/// </summary>
public IReadOnlyList<string> FlagKeysToEvaluate { get; init; } = [];

/// <summary>
/// Whether to disable GeoIP enrichment for the feature flag request. Defaults to <c>false</c>.
/// </summary>
public bool DisableGeoIp { get; init; }
}
1 change: 1 addition & 0 deletions src/PostHog/PostHogClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,7 @@ async Task<FlagsResult> FetchFlagsAsync(string distId, CancellationToken ctx)
options?.PersonProperties,
options?.Groups,
options?.FlagKeysToEvaluate,
options?.DisableGeoIp ?? false,
ctx);
return results.ToFlagsResult();
}
Expand Down
2 changes: 2 additions & 0 deletions src/PostHog/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -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
63 changes: 51 additions & 12 deletions tests/UnitTests/Features/FeatureFlagsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -2382,21 +2384,58 @@ public async Task CallsDecideWithFlagKeyToEvaluate()
);
var client = container.Activate<PostHogClient>();

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<PostHogClient>();

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]
Expand Down
Loading