From 8dd3c5673f8b32312d47c41d91f3032a875f087b Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Mon, 25 May 2026 15:44:23 -0300 Subject: [PATCH 1/4] fix: include group context in $feature_flag_called dedupe key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In `_capture_feature_flag_called_if_needed`, the per-`distinct_id` dedupe key only included the flag key and response. 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 the sorted `groups` hash in the dedupe key so that the same `(distinct_id, flag, response)` combination fires once per distinct group context. Repeated calls under the same group context still dedupe, and calls that pass the same map in a different key order still dedupe (the groups are canonicalized via `groups.sort.to_json` before being mixed into the dedup 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. Generated-By: PostHog Code Task-Id: d94308d9-7655-4bac-8f15-c61478b5fca1 --- lib/posthog/client.rb | 14 ++++- spec/posthog/client_spec.rb | 106 ++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 1f04a5f..974da64 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'time' +require 'json' require 'securerandom' require 'posthog/defaults' @@ -760,11 +761,22 @@ def property_key?(properties, key) # Shared by the legacy single-flag path ({#get_feature_flag_result}) and the # snapshot's access-recording. Owns dedup-key construction, the # per-distinct_id sent-flags cache, and the `$feature_flag_called` capture call. + # Group context is included in the dedup key so group-scoped flags fire a + # separate event for each group a user is evaluated under. def _capture_feature_flag_called_if_needed( distinct_id: nil, key: nil, response: nil, properties: nil, groups: nil, disable_geoip: nil ) - reported_key = "#{key}_#{response.nil? ? '::null::' : response}" + response_repr = response.nil? ? '::null::' : response + groups_repr = + if groups && !groups.empty? + # Canonicalize so two equal hashes with keys inserted in a different + # order produce the same dedup key. + "_#{groups.sort.to_json}" + else + '' + end + reported_key = "#{key}_#{response_repr}#{groups_repr}" return if @distinct_id_has_sent_flag_calls[distinct_id].include?(reported_key) msg = { diff --git a/spec/posthog/client_spec.rb b/spec/posthog/client_spec.rb index ee4895a..7b7d5ae 100644 --- a/spec/posthog/client_spec.rb +++ b/spec/posthog/client_spec.rb @@ -575,6 +575,112 @@ module PostHog )).to eq(true) end + it '$feature_flag_called fires per group context (group-scoped dedup)' do + api_feature_flag_res = { + 'flags' => [ + { + 'id' => 1, + 'name' => 'Group flag', + 'key' => 'group-flag', + 'active' => true, + 'filters' => { + 'groups' => [ + { 'properties' => [], 'rollout_percentage' => 100 } + ] + } + } + ] + } + + stub_request( + :get, + 'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true' + ).to_return(status: 200, body: api_feature_flag_res.to_json) + + c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + allow(c).to receive(:capture) + + # Same user, same flag, same response — but two different group contexts. + expect(c).to receive(:capture).with(hash_including( + distinct_id: 'user-1', + event: '$feature_flag_called', + groups: { organization: 'org-a' } + )).exactly(1).times + expect(c).to receive(:capture).with(hash_including( + distinct_id: 'user-1', + event: '$feature_flag_called', + groups: { organization: 'org-b' } + )).exactly(1).times + + c.get_feature_flag('group-flag', 'user-1', groups: { organization: 'org-a' }) + c.get_feature_flag('group-flag', 'user-1', groups: { organization: 'org-b' }) + end + + it '$feature_flag_called dedupes across repeated calls under the same group context' do + api_feature_flag_res = { + 'flags' => [ + { + 'id' => 1, + 'name' => 'Group flag', + 'key' => 'group-flag', + 'active' => true, + 'filters' => { + 'groups' => [ + { 'properties' => [], 'rollout_percentage' => 100 } + ] + } + } + ] + } + + stub_request( + :get, + 'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true' + ).to_return(status: 200, body: api_feature_flag_res.to_json) + + c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + allow(c).to receive(:capture) + + expect(c).to receive(:capture).with(hash_including( + event: '$feature_flag_called', + groups: { organization: 'org-a' } + )).exactly(1).times + + c.get_feature_flag('group-flag', 'user-1', groups: { organization: 'org-a' }) + c.get_feature_flag('group-flag', 'user-1', groups: { organization: 'org-a' }) + end + + it '$feature_flag_called dedupes when same groups are passed in different key order' do + api_feature_flag_res = { + 'flags' => [ + { + 'id' => 1, + 'name' => 'Group flag', + 'key' => 'group-flag', + 'active' => true, + 'filters' => { + 'groups' => [ + { 'properties' => [], 'rollout_percentage' => 100 } + ] + } + } + ] + } + + stub_request( + :get, + 'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true' + ).to_return(status: 200, body: api_feature_flag_res.to_json) + + c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + allow(c).to receive(:capture) + + expect(c).to receive(:capture).with(hash_including(event: '$feature_flag_called')).exactly(1).times + + c.get_feature_flag('group-flag', 'user-1', groups: { organization: 'org-a', team: 'red' }) + c.get_feature_flag('group-flag', 'user-1', groups: { team: 'red', organization: 'org-a' }) + end + it 'captures groups' do client.capture( { From 00cde384d76dbe30546bbc2d0a18aa23a54e3705 Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Mon, 25 May 2026 16:16:17 -0300 Subject: [PATCH 2/4] chore: add changeset Generated-By: PostHog Code Task-Id: d94308d9-7655-4bac-8f15-c61478b5fca1 --- .changeset/include-group-context-in-flag-called-dedupe.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/include-group-context-in-flag-called-dedupe.md diff --git a/.changeset/include-group-context-in-flag-called-dedupe.md b/.changeset/include-group-context-in-flag-called-dedupe.md new file mode 100644 index 0000000..d2816d9 --- /dev/null +++ b/.changeset/include-group-context-in-flag-called-dedupe.md @@ -0,0 +1,5 @@ +--- +'posthog-ruby': patch +--- + +Include group context in the `$feature_flag_called` dedupe key so group-scoped flags fire a separate event for each group a user is evaluated under, instead of being dedup-ed against the first group context the same `(distinct_id, flag, response)` was seen under. From 17cc50fc2e802365d631df1faf29f87bc74d0144 Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Wed, 27 May 2026 15:36:52 -0300 Subject: [PATCH 3/4] refactor(spec): extract shared setup for group-context dedup tests into context block --- spec/posthog/client_spec.rb | 150 ++++++++++++------------------------ 1 file changed, 51 insertions(+), 99 deletions(-) diff --git a/spec/posthog/client_spec.rb b/spec/posthog/client_spec.rb index 7b7d5ae..3341437 100644 --- a/spec/posthog/client_spec.rb +++ b/spec/posthog/client_spec.rb @@ -575,110 +575,62 @@ module PostHog )).to eq(true) end - it '$feature_flag_called fires per group context (group-scoped dedup)' do - api_feature_flag_res = { - 'flags' => [ - { - 'id' => 1, - 'name' => 'Group flag', - 'key' => 'group-flag', - 'active' => true, - 'filters' => { - 'groups' => [ - { 'properties' => [], 'rollout_percentage' => 100 } - ] - } - } - ] - } - - stub_request( - :get, - 'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true' - ).to_return(status: 200, body: api_feature_flag_res.to_json) - - c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) - allow(c).to receive(:capture) - - # Same user, same flag, same response — but two different group contexts. - expect(c).to receive(:capture).with(hash_including( - distinct_id: 'user-1', - event: '$feature_flag_called', - groups: { organization: 'org-a' } - )).exactly(1).times - expect(c).to receive(:capture).with(hash_including( - distinct_id: 'user-1', - event: '$feature_flag_called', - groups: { organization: 'org-b' } - )).exactly(1).times - - c.get_feature_flag('group-flag', 'user-1', groups: { organization: 'org-a' }) - c.get_feature_flag('group-flag', 'user-1', groups: { organization: 'org-b' }) - end - - it '$feature_flag_called dedupes across repeated calls under the same group context' do - api_feature_flag_res = { - 'flags' => [ - { - 'id' => 1, - 'name' => 'Group flag', - 'key' => 'group-flag', - 'active' => true, - 'filters' => { - 'groups' => [ - { 'properties' => [], 'rollout_percentage' => 100 } - ] - } - } - ] - } - - stub_request( - :get, - 'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true' - ).to_return(status: 200, body: api_feature_flag_res.to_json) - - c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) - allow(c).to receive(:capture) - - expect(c).to receive(:capture).with(hash_including( - event: '$feature_flag_called', - groups: { organization: 'org-a' } - )).exactly(1).times - - c.get_feature_flag('group-flag', 'user-1', groups: { organization: 'org-a' }) - c.get_feature_flag('group-flag', 'user-1', groups: { organization: 'org-a' }) - end - - it '$feature_flag_called dedupes when same groups are passed in different key order' do - api_feature_flag_res = { - 'flags' => [ - { - 'id' => 1, - 'name' => 'Group flag', - 'key' => 'group-flag', - 'active' => true, - 'filters' => { - 'groups' => [ - { 'properties' => [], 'rollout_percentage' => 100 } - ] + context '$feature_flag_called group-context deduplication' do + let(:group_flag_api_res) do + { + 'flags' => [ + { + 'id' => 1, + 'name' => 'Group flag', + 'key' => 'group-flag', + 'active' => true, + 'filters' => { + 'groups' => [ + { 'properties' => [], 'rollout_percentage' => 100 } + ] + } } - } - ] - } + ] + } + end - stub_request( - :get, - 'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true' - ).to_return(status: 200, body: api_feature_flag_res.to_json) + let(:group_flag_client) do + stub_request( + :get, + 'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true' + ).to_return(status: 200, body: group_flag_api_res.to_json) + c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + allow(c).to receive(:capture) + c + end - c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) - allow(c).to receive(:capture) + it 'fires once per distinct group context' do + expect(group_flag_client).to receive(:capture).with(hash_including( + distinct_id: 'user-1', + event: '$feature_flag_called', + groups: { organization: 'org-a' } + )).exactly(1).times + expect(group_flag_client).to receive(:capture).with(hash_including( + distinct_id: 'user-1', + event: '$feature_flag_called', + groups: { organization: 'org-b' } + )).exactly(1).times + + group_flag_client.get_feature_flag('group-flag', 'user-1', groups: { organization: 'org-a' }) + group_flag_client.get_feature_flag('group-flag', 'user-1', groups: { organization: 'org-b' }) + end - expect(c).to receive(:capture).with(hash_including(event: '$feature_flag_called')).exactly(1).times + [ + ['repeated calls with the same context', { organization: 'org-a' }, { organization: 'org-a' }], + ['same groups in different key order', { organization: 'org-a', team: 'red' }, { team: 'red', organization: 'org-a' }] + ].each do |description, first_groups, second_groups| + it "dedupes on #{description}" do + expect(group_flag_client).to receive(:capture).with(hash_including(event: '$feature_flag_called')).exactly(1).times - c.get_feature_flag('group-flag', 'user-1', groups: { organization: 'org-a', team: 'red' }) - c.get_feature_flag('group-flag', 'user-1', groups: { team: 'red', organization: 'org-a' }) + group_flag_client.get_feature_flag('group-flag', 'user-1', groups: first_groups) + group_flag_client.get_feature_flag('group-flag', 'user-1', groups: second_groups) + end + end end it 'captures groups' do From e234edd61bbd1fb9b09b97e21f1324c334dd545b Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Wed, 27 May 2026 15:53:30 -0300 Subject: [PATCH 4/4] style: fix rubocop line length offenses in client_spec --- spec/posthog/client_spec.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/spec/posthog/client_spec.rb b/spec/posthog/client_spec.rb index 3341437..60692f5 100644 --- a/spec/posthog/client_spec.rb +++ b/spec/posthog/client_spec.rb @@ -622,10 +622,13 @@ module PostHog [ ['repeated calls with the same context', { organization: 'org-a' }, { organization: 'org-a' }], - ['same groups in different key order', { organization: 'org-a', team: 'red' }, { team: 'red', organization: 'org-a' }] + ['same groups in different key order', + { organization: 'org-a', team: 'red' }, + { team: 'red', organization: 'org-a' }] ].each do |description, first_groups, second_groups| it "dedupes on #{description}" do - expect(group_flag_client).to receive(:capture).with(hash_including(event: '$feature_flag_called')).exactly(1).times + expect(group_flag_client) + .to receive(:capture).with(hash_including(event: '$feature_flag_called')).exactly(1).times group_flag_client.get_feature_flag('group-flag', 'user-1', groups: first_groups) group_flag_client.get_feature_flag('group-flag', 'user-1', groups: second_groups)