Skip to content

fix: include group context in $feature_flag_called dedupe key#209

Merged
gustavohstrassburger merged 3 commits into
mainfrom
posthog-code/fix-flag-called-dedupe-groups
May 28, 2026
Merged

fix: include group context in $feature_flag_called dedupe key#209
gustavohstrassburger merged 3 commits into
mainfrom
posthog-code/fix-flag-called-dedupe-groups

Conversation

@gustavohstrassburger
Copy link
Copy Markdown
Contributor

@gustavohstrassburger gustavohstrassburger commented May 25, 2026

💡 Motivation and Context

In PostHogClient.TryCaptureDedupedFeatureFlagCalledEvent (src/PostHog/PostHogClient.cs), 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.

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 via OrderBy(GroupType, StringComparer.Ordinal)).

dotnet test --filter "FullyQualifiedName~Feature" is green: 322 tests pass on net8.0, 312 on netcoreapp3.1.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

Created with PostHog Code

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
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 25, 2026

posthog-dotnet Compliance Report

Date: 2026-05-28 15:33:26 UTC
Duration: 556ms

⚠️ Some Tests Failed

2/16 tests passed, 14 failed


Feature_Flags Tests

⚠️ 2/16 tests passed, 14 failed

View Details
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
@gustavohstrassburger gustavohstrassburger marked this pull request as ready for review May 25, 2026 21:02
@gustavohstrassburger gustavohstrassburger requested review from a team and haacked as code owners May 25, 2026 21:02
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 25, 2026

Prompt To Fix All With AI
Fix 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

Comment thread tests/UnitTests/Features/FeatureFlagsTests.cs Outdated
Comment thread src/PostHog/PostHogClient.cs
Copy link
Copy Markdown
Contributor

@haacked haacked left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice fix. A few suggestions and nits inline; nothing blocking.

Comment thread src/PostHog/PostHogClient.cs
// 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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will address this in a follow up PR.

Comment thread tests/UnitTests/Features/FeatureFlagsTests.cs Outdated
Comment thread tests/UnitTests/Features/FeatureFlagsTests.cs Outdated
Comment thread src/PostHog/PostHogClient.cs
Comment thread src/PostHog/PostHogClient.cs Outdated
Comment thread src/PostHog/PostHogClient.cs Outdated
Comment thread src/PostHog/PostHogClient.cs Outdated
- 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
@gustavohstrassburger gustavohstrassburger merged commit f0b7308 into main May 28, 2026
14 checks passed
@gustavohstrassburger gustavohstrassburger deleted the posthog-code/fix-flag-called-dedupe-groups branch May 28, 2026 16:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$feature_flag_called dedupe does not include group context for group-scoped flags

3 participants