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. 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( {