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..60692f5 100644 --- a/spec/posthog/client_spec.rb +++ b/spec/posthog/client_spec.rb @@ -575,6 +575,67 @@ module PostHog )).to eq(true) end + 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 + + 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 + + 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 + + [ + ['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 + + 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 client.capture( {