fix: include group context in $feature_flag_called dedupe key#209
Conversation
In `PostHogClient.TryCaptureDedupedFeatureFlagCalledEvent`, the per-`distinctId` `MemoryCache` key only included `(distinctId, featureKey, cacheKeyValue)`. For group-scoped flags this meant that when the same user was evaluated under a different group, no new `$feature_flag_called` event was fired — causing per-group exposure undercount for experiments scoped to a group key. Include a canonical representation of the `GroupCollection` in the cache key (new `CanonicalGroupsCacheKey` helper) so the same `(user, flag, response)` fires once per distinct group context. Repeated calls under the same group context still dedupe; group collections built in a different insertion order also dedupe (canonicalized via `OrderBy(GroupType, StringComparer.Ordinal)`). Mirrors the posthog-node fix in PostHog/posthog-js#3658 (which closes PostHog/posthog-js#3651). Both SDKs share the same dedupe shape, so backend evaluation needs the same change. Generated-By: PostHog Code Task-Id: d94308d9-7655-4bac-8f15-c61478b5fca1
posthog-dotnet Compliance ReportDate: 2026-05-28 15:33:26 UTC
|
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ❌ | 44ms |
| Request Payload.Flags Request Uses V2 Query Param | ❌ | 25ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ❌ | 6ms |
| Request Payload.Flags Request Omits Authorization Header | ❌ | 5ms |
| Request Payload.Token In Flags Body Matches Init | ❌ | 5ms |
| Request Payload.Groups Round Trip | ❌ | 5ms |
| Request Payload.Groups Default To Empty Object | ❌ | 4ms |
| Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It | ❌ | 5ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ❌ | 5ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 5ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ❌ | 5ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 3ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 182ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ❌ | 7ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ❌ | 5ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ❌ | 5ms |
Failures
request_payload.request_with_person_properties_device_id
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.flags_request_uses_v2_query_param
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.flags_request_hits_flags_path_not_decide
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.flags_request_omits_authorization_header
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.token_in_flags_body_matches_init
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.groups_round_trip
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.groups_default_to_empty_object
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.person_properties_distinct_id_auto_populated_when_caller_omits_it
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.disable_geoip_false_propagates_as_geoip_disable_false
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.disable_geoip_omitted_defaults_to_false
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.flag_keys_to_evaluate_contains_only_requested_key
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_lifecycle.two_flag_calls_produce_two_remote_requests
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_lifecycle.mock_response_value_is_returned_to_caller
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
side_effect_events.get_feature_flag_captures_feature_flag_called_event
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
Generated-By: PostHog Code Task-Id: d94308d9-7655-4bac-8f15-c61478b5fca1
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
tests/UnitTests/Features/FeatureFlagsTests.cs:297-375
**Three `[Fact]` tests could be a parameterised `[Theory]`**
The three new tests cover the same logical property — dedup count as a function of group context — and share nearly identical scaffolding. Per the team's preference for parameterised tests, grouping them into a `[Theory]` with `[MemberData]` (or at least the first two as `[InlineData]`) would remove the duplicated container/handler/client setup and make the intent clearer at a glance.
### Issue 2 of 2
src/PostHog/PostHogClient.cs:776-780
**Potential cache-key collision with delimiter characters**
The canonical key format concatenates `GroupType` and `GroupKey` with `=` and joins entries with `;`. If either field contains those delimiters, two distinct group configurations can produce the same string, causing a false dedup where a `$feature_flag_called` event is suppressed for a genuinely different group context. Escaping the components before joining eliminates the ambiguity.
```suggestion
var pairs = groups
.Select(g => (g.GroupType, g.GroupKey))
.OrderBy(p => p.GroupType, StringComparer.Ordinal)
.Select(p => Uri.EscapeDataString(p.GroupType) + "=" + Uri.EscapeDataString(p.GroupKey));
return string.Join(";", pairs);
```
Reviews (1): Last reviewed commit: "chore: add changeset" | Re-trigger Greptile |
haacked
left a comment
There was a problem hiding this comment.
Nice fix. A few suggestions and nits inline; nothing blocking.
| // so two equal sets of groups always produce the same dedup cache key. | ||
| // Returns the empty string when no groups were passed, which keeps the legacy | ||
| // "no groups" dedupe shape for callers that don't pass a GroupCollection. | ||
| static string CanonicalGroupsCacheKey(GroupCollection? groups) |
There was a problem hiding this comment.
suggestion: This is the second order-independent group-canonicalizer in the repo. FeatureFlagCacheKey.Generate at src/PostHog/Features/FeatureFlagCacheKey.cs:38-65 already does the same job for the flag-evaluation cache, using OrderBy(g => g.GroupType) (default culture-sensitive comparer) and , between entries. This one uses StringComparer.Ordinal and ;. Two helpers solving the same sub-problem will drift the next time either is touched, and a future reader will assume the difference is meaningful when it isn't.
Worth a follow-up (not this PR): align both on StringComparer.Ordinal (the right choice for cache keys — deterministic across cultures, faster), or factor a small Group.OrderedByType(IEnumerable<Group>) helper that both call sites compose from.
Same point on placement: this helper doesn't touch any PostHogClient state, so it would sit more naturally next to GroupCollection.
There was a problem hiding this comment.
Will address this in a follow up PR.
- Escape group type/key with Uri.EscapeDataString in CanonicalGroupsCacheKey to avoid `=`/`;` delimiter collisions. - Drop the intermediate tuple Select and order Groups directly. - Reword the dedupe-shape comment to not call the no-groups path "legacy". - Match the factory comment to the actual variable names in the cache tuple. - Collapse the three group-context dedupe Facts into one MemberData Theory. Generated-By: PostHog Code Task-Id: fe3e6da7-66a3-4fad-95bf-59d5df4557ee
💡 Motivation and Context
In
PostHogClient.TryCaptureDedupedFeatureFlagCalledEvent(src/PostHog/PostHogClient.cs), the per-distinctIdMemoryCachekey only included(distinctId, featureKey, cacheKeyValue). For group-scoped flags this meant that when the same user was evaluated under a different group, no new$feature_flag_calledevent was fired — causing per-group exposure undercount for experiments scoped to a group key.Mirrors the posthog-node fix in PostHog/posthog-js#3658 (which closes PostHog/posthog-js#3651). Both SDKs share the same dedupe shape, so backend evaluation needs the same change.
💚 How did you test it?
Added three xUnit tests in
tests/UnitTests/Features/FeatureFlagsTests.cs:CapturesSeparateFeatureFlagCalledEventsPerGroupContext— same user, same flag, two different group contexts → two events fired.DedupesFeatureFlagCalledAcrossRepeatedCallsUnderSameGroupContext— repeated access under the same group → one event.DedupesFeatureFlagCalledAcrossGroupInsertionOrder— same groups added in different insertion order → one event (canonicalized viaOrderBy(GroupType, StringComparer.Ordinal)).dotnet test --filter "FullyQualifiedName~Feature"is green: 322 tests pass on net8.0, 312 on netcoreapp3.1.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset fileCreated with PostHog Code