Skip to content

Commit 211aa24

Browse files
authored
fix: satisfy SDK compliance harness 0.8.0 (#248)
* chore: add SDK compliance harness 0.8.0 * test: address feature flag review feedback * chore: remove harness audit notes * chore: add PostHog changeset * chore: correct PostHog changeset * fix: keep flags api key field * fix: preserve explicit person distinct id
1 parent fd2c633 commit 211aa24

10 files changed

Lines changed: 190 additions & 22 deletions

File tree

.changeset/lucky-geese-flags.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"PostHog": patch
3+
---
4+
5+
Add a feature flag request option for disabling GeoIP enrichment.

.github/workflows/sdk-compliance.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ on:
1414
jobs:
1515
compliance:
1616
name: PostHog SDK compliance tests
17-
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@39d05346c4638d24f94e591ab89c9c6fcdb52d6b
17+
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@be8b8d5a3f94a249659844e94832e874f049c1e4
1818
with:
1919
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile"
2020
adapter-context: "."
21-
test-harness-version: "latest"
21+
test-harness-version: "0.8.0"

sdk_compliance_adapter/CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ docker run -d --name sdk-adapter --network test-network -p 8080:8080 posthog-dot
3535
docker run --rm \
3636
--name test-harness \
3737
--network test-network \
38-
ghcr.io/posthog/sdk-test-harness:latest \
38+
ghcr.io/posthog/sdk-test-harness:0.8.0 \
3939
run --adapter-url http://sdk-adapter:8080 --mock-url http://test-harness:8081
4040

4141
# Cleanup

sdk_compliance_adapter/Program.cs

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using PostHog;
66
using PostHog.Versioning;
77

8+
const int StaleEventDrainDelayMs = 100;
9+
810
var builder = WebApplication.CreateBuilder(args);
911

1012
// Configure JSON options for consistent serialization
@@ -102,6 +104,58 @@
102104
return Results.Ok(new { success = true });
103105
});
104106

107+
app.MapPost("/get_feature_flag", async (FeatureFlagRequest request) =>
108+
{
109+
if (state.Client is null)
110+
{
111+
return Results.BadRequest(new { error = "SDK not initialized" });
112+
}
113+
114+
if (string.IsNullOrEmpty(request.Key) || string.IsNullOrEmpty(request.DistinctId))
115+
{
116+
return Results.BadRequest(new { error = "key and distinct_id are required" });
117+
}
118+
119+
try
120+
{
121+
var options = new FeatureFlagOptions
122+
{
123+
PersonProperties = request.PersonProperties,
124+
Groups = ToGroupCollection(request.Groups, request.GroupProperties),
125+
FlagKeysToEvaluate = [request.Key],
126+
OnlyEvaluateLocally = request.ForceRemote == false,
127+
DisableGeoIp = request.DisableGeoIp ?? false
128+
};
129+
130+
#pragma warning disable CS0618
131+
var flag = await state.Client.GetFeatureFlagAsync(
132+
request.Key,
133+
request.DistinctId,
134+
options,
135+
CancellationToken.None);
136+
#pragma warning restore CS0618
137+
138+
// Feature-flag evaluation captures a documented $feature_flag_called side-effect event.
139+
// Flush it in the same adapter action so a later /reset does not send stale events into
140+
// the next harness test's freshly-reset mock server. Keep a named drain delay aligned with
141+
// the /flush endpoint so async send completion timing is explicit and tunable.
142+
await state.Client.FlushAsync();
143+
await Task.Delay(StaleEventDrainDelayMs);
144+
145+
return Results.Ok(new
146+
{
147+
success = true,
148+
value = flag?.VariantKey ?? (object?)(flag?.IsEnabled ?? false)
149+
});
150+
}
151+
catch (Exception ex)
152+
{
153+
Console.Error.WriteLine($"Feature flag error: {ex}");
154+
state.RecordError(ex.Message);
155+
return Results.StatusCode(500);
156+
}
157+
});
158+
105159
app.MapPost("/flush", async () =>
106160
{
107161
if (state.Client is null)
@@ -113,8 +167,8 @@
113167
{
114168
await state.Client.FlushAsync();
115169

116-
// Wait a bit for any pending requests to complete
117-
await Task.Delay(100);
170+
// Wait a bit for any pending requests to complete.
171+
await Task.Delay(StaleEventDrainDelayMs);
118172
}
119173
catch (Exception ex)
120174
{
@@ -143,6 +197,48 @@
143197
return Results.Ok(new { success = true });
144198
});
145199

200+
GroupCollection? ToGroupCollection(
201+
Dictionary<string, object?>? groups,
202+
Dictionary<string, Dictionary<string, object?>>? groupProperties)
203+
{
204+
if ((groups is null || groups.Count == 0) && (groupProperties is null || groupProperties.Count == 0))
205+
{
206+
return null;
207+
}
208+
209+
var collection = new GroupCollection();
210+
if (groups is null)
211+
{
212+
return collection;
213+
}
214+
215+
foreach (var (groupType, groupKeyValue) in groups)
216+
{
217+
var groupKey = ToStringValue(groupKeyValue);
218+
if (groupKey is null)
219+
{
220+
continue;
221+
}
222+
223+
var properties = groupProperties is not null && groupProperties.TryGetValue(groupType, out var props)
224+
? props
225+
: [];
226+
collection.Add(new Group(groupType, groupKey, properties));
227+
}
228+
229+
return collection;
230+
}
231+
232+
static string? ToStringValue(object? value) => value switch
233+
{
234+
null => null,
235+
string s => s,
236+
JsonElement { ValueKind: JsonValueKind.Null } => null,
237+
JsonElement { ValueKind: JsonValueKind.String } json => json.GetString(),
238+
JsonElement json => json.ToString(),
239+
_ => value.ToString()
240+
};
241+
146242
app.Run();
147243

148244
// --- Models ---
@@ -170,6 +266,16 @@ record CaptureRequest(
170266
[property: JsonPropertyName("timestamp")] string? Timestamp = null
171267
);
172268

269+
record FeatureFlagRequest(
270+
[property: JsonPropertyName("key")] string Key,
271+
[property: JsonPropertyName("distinct_id")] string DistinctId,
272+
[property: JsonPropertyName("person_properties")] Dictionary<string, object?>? PersonProperties = null,
273+
[property: JsonPropertyName("groups")] Dictionary<string, object?>? Groups = null,
274+
[property: JsonPropertyName("group_properties")] Dictionary<string, Dictionary<string, object?>>? GroupProperties = null,
275+
[property: JsonPropertyName("disable_geoip")] bool? DisableGeoIp = null,
276+
[property: JsonPropertyName("force_remote")] bool? ForceRemote = null
277+
);
278+
173279
record StateResponse(
174280
[property: JsonPropertyName("pending_events")] int PendingEvents,
175281
[property: JsonPropertyName("total_events_captured")] int TotalEventsCaptured,

sdk_compliance_adapter/docker-compose.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,12 @@ services:
44
build:
55
context: ..
66
dockerfile: sdk_compliance_adapter/Dockerfile
7-
ports:
8-
- "8080:8080"
97
networks:
108
- test-network
119

1210
# Test harness
1311
test-harness:
14-
image: ghcr.io/posthog/sdk-test-harness:latest
12+
image: ghcr.io/posthog/sdk-test-harness:0.8.0
1513
command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081"]
1614
networks:
1715
- test-network

src/PostHog/Api/PostHogApiClient.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,25 +107,37 @@ public async Task<ApiResult> SendEventAsync(
107107
/// <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>
108108
/// <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>
109109
/// <param name="flagKeysToEvaluate">The set of flag keys to evaluate. If empty, this returns all flags.</param>
110+
/// <param name="disableGeoIp">Whether to disable GeoIP enrichment for the request.</param>
110111
/// <param name="cancellationToken">The cancellation token that can be used to cancel the operation.</param>
111112
/// <returns>A <see cref="FlagsApiResult"/>.</returns>
112113
public async Task<FlagsApiResult?> GetFeatureFlagsAsync(
113114
string distinctUserId,
114115
Dictionary<string, object?>? personProperties,
115116
GroupCollection? groupProperties,
116117
IReadOnlyList<string>? flagKeysToEvaluate,
118+
bool disableGeoIp,
117119
CancellationToken cancellationToken)
118120
{
119121
var endpointUrl = new Uri(HostUrl, "flags/?v=2");
120122

121123
var payload = new Dictionary<string, object>
122124
{
123-
["distinct_id"] = distinctUserId
125+
["api_key"] = ProjectToken,
126+
["distinct_id"] = distinctUserId,
127+
["groups"] = new Dictionary<string, string>(),
128+
["group_properties"] = new Dictionary<string, Dictionary<string, object?>>(),
129+
["geoip_disable"] = disableGeoIp
124130
};
125131

126132
if (personProperties is { Count: > 0 })
127133
{
128-
payload["person_properties"] = personProperties;
134+
var mergedPersonProperties = new Dictionary<string, object?>(personProperties);
135+
if (!mergedPersonProperties.ContainsKey("distinct_id"))
136+
{
137+
mergedPersonProperties["distinct_id"] = distinctUserId;
138+
}
139+
140+
payload["person_properties"] = mergedPersonProperties;
129141
}
130142

131143
if (flagKeysToEvaluate is { Count: > 0 })

src/PostHog/Features/FeatureFlagOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,9 @@ public class AllFeatureFlagsOptions
4949
/// The set of flag keys to evaluate in this request. If not specified, all flags are evaluated.
5050
/// </summary>
5151
public IReadOnlyList<string> FlagKeysToEvaluate { get; init; } = [];
52+
53+
/// <summary>
54+
/// Whether to disable GeoIP enrichment for the feature flag request. Defaults to <c>false</c>.
55+
/// </summary>
56+
public bool DisableGeoIp { get; init; }
5257
}

src/PostHog/PostHogClient.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,6 +1146,7 @@ async Task<FlagsResult> FetchFlagsAsync(string distId, CancellationToken ctx)
11461146
options?.PersonProperties,
11471147
options?.Groups,
11481148
options?.FlagKeysToEvaluate,
1149+
options?.DisableGeoIp ?? false,
11491150
ctx);
11501151
return results.ToFlagsResult();
11511152
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
#nullable enable
2+
PostHog.AllFeatureFlagsOptions.DisableGeoIp.get -> bool
3+
PostHog.AllFeatureFlagsOptions.DisableGeoIp.init -> void
24
PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.get -> int
35
PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.set -> void

tests/UnitTests/Features/FeatureFlagsTests.cs

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2371,8 +2371,10 @@ public async Task MultivariateFeatureFlagPayloads()
23712371
JsonAssert.Equal("""{"a":"json"}""", result?.Payload);
23722372
}
23732373

2374-
[Fact]
2375-
public async Task CallsDecideWithFlagKeyToEvaluate()
2374+
[Theory]
2375+
[InlineData(false)]
2376+
[InlineData(true)]
2377+
public async Task CallsDecideWithFlagKeyToEvaluate(bool disableGeoIp)
23762378
{
23772379
var container = new TestContainer();
23782380
var handler = container.FakeHttpMessageHandler.AddFlagsResponse(
@@ -2382,21 +2384,58 @@ public async Task CallsDecideWithFlagKeyToEvaluate()
23822384
);
23832385
var client = container.Activate<PostHogClient>();
23842386

2385-
var result = await client.GetFeatureFlagAsync("beta-feature", "some-distinct-id");
2387+
var result = await client.GetFeatureFlagAsync(
2388+
"beta-feature",
2389+
"some-distinct-id",
2390+
new FeatureFlagOptions
2391+
{
2392+
DisableGeoIp = disableGeoIp,
2393+
FlagKeysToEvaluate = ["beta-feature"]
2394+
});
23862395

23872396
Assert.NotNull(result);
23882397
Assert.Equal(new FeatureFlag { Key = "beta-feature", VariantKey = "alakazam" }, result);
2389-
var receivedBody = handler.GetReceivedRequestBody(true);
2390-
Assert.StartsWith(
2398+
using var document = JsonDocument.Parse(handler.GetReceivedRequestBody(indented: false));
2399+
var root = document.RootElement;
2400+
Assert.Equal("fake-project-token", root.GetProperty("api_key").GetString());
2401+
Assert.Equal("some-distinct-id", root.GetProperty("distinct_id").GetString());
2402+
Assert.Empty(root.GetProperty("groups").EnumerateObject());
2403+
Assert.Empty(root.GetProperty("group_properties").EnumerateObject());
2404+
Assert.Equal(disableGeoIp, root.GetProperty("geoip_disable").GetBoolean());
2405+
var flagKey = Assert.Single(root.GetProperty("flag_keys_to_evaluate").EnumerateArray());
2406+
Assert.Equal("beta-feature", flagKey.GetString());
2407+
}
2408+
2409+
[Fact]
2410+
public async Task PreservesExplicitPersonPropertiesDistinctId()
2411+
{
2412+
var container = new TestContainer();
2413+
var handler = container.FakeHttpMessageHandler.AddFlagsResponse(
2414+
"""
2415+
{"featureFlags": {"beta-feature": true}}
23912416
"""
2417+
);
2418+
var client = container.Activate<PostHogClient>();
2419+
2420+
var result = await client.GetFeatureFlagAsync(
2421+
"beta-feature",
2422+
"top-level-distinct-id",
2423+
new FeatureFlagOptions
23922424
{
2393-
"distinct_id": "some-distinct-id",
2394-
"flag_keys_to_evaluate": [
2395-
"beta-feature"
2396-
],
2397-
""",
2398-
receivedBody,
2399-
StringComparison.Ordinal);
2425+
PersonProperties = new()
2426+
{
2427+
["distinct_id"] = "person-property-distinct-id",
2428+
["email"] = "test@posthog.com"
2429+
}
2430+
});
2431+
2432+
Assert.True(result);
2433+
using var document = JsonDocument.Parse(handler.GetReceivedRequestBody(indented: false));
2434+
var root = document.RootElement;
2435+
Assert.Equal("top-level-distinct-id", root.GetProperty("distinct_id").GetString());
2436+
var personProperties = root.GetProperty("person_properties");
2437+
Assert.Equal("person-property-distinct-id", personProperties.GetProperty("distinct_id").GetString());
2438+
Assert.Equal("test@posthog.com", personProperties.GetProperty("email").GetString());
24002439
}
24012440

24022441
[Fact]

0 commit comments

Comments
 (0)