diff --git a/lib/posthog.rb b/lib/posthog.rb index 618aa9f..0d9b085 100644 --- a/lib/posthog.rb +++ b/lib/posthog.rb @@ -11,3 +11,4 @@ require 'posthog/logging' require 'posthog/exception_capture' require 'posthog/feature_flag_error' +require 'posthog/feature_flag_result' diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index e9952f5..8e9355c 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -278,6 +278,40 @@ def get_feature_flag( group_properties: {}, only_evaluate_locally: false, send_feature_flag_events: true + ) + result = get_feature_flag_result( + key, + distinct_id, + groups: groups, + person_properties: person_properties, + group_properties: group_properties, + only_evaluate_locally: only_evaluate_locally, + send_feature_flag_events: send_feature_flag_events + ) + result&.value + end + + # Returns both the feature flag value and payload in a single call. + # This method raises the $feature_flag_called event with the payload included. + # + # @param [String] key The key of the feature flag + # @param [String] distinct_id The distinct id of the user + # @param [Hash] groups + # @param [Hash] person_properties key-value pairs of properties to associate with the user. + # @param [Hash] group_properties + # @param [Boolean] only_evaluate_locally + # @param [Boolean] send_feature_flag_events + # + # @return [FeatureFlagResult, nil] A FeatureFlagResult object containing the flag value and payload, + # or nil if the flag evaluation returned nil + def get_feature_flag_result( + key, + distinct_id, + groups: {}, + person_properties: {}, + group_properties: {}, + only_evaluate_locally: false, + send_feature_flag_events: true ) person_properties, group_properties = add_local_person_and_group_properties( distinct_id, @@ -285,7 +319,7 @@ def get_feature_flag( person_properties, group_properties ) - feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error = + feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload = @feature_flags_poller.get_feature_flag( key, distinct_id, @@ -294,8 +328,8 @@ def get_feature_flag( group_properties, only_evaluate_locally ) - feature_flag_reported_key = "#{key}_#{feature_flag_response}" + if !@distinct_id_has_sent_flag_calls[distinct_id].include?(feature_flag_reported_key) && send_feature_flag_events properties = { '$feature_flag' => key, @@ -307,16 +341,15 @@ def get_feature_flag( properties['$feature_flag_error'] = feature_flag_error if feature_flag_error capture( - { - distinct_id: distinct_id, - event: '$feature_flag_called', - properties: properties, - groups: groups - } + distinct_id: distinct_id, + event: '$feature_flag_called', + properties: properties, + groups: groups ) @distinct_id_has_sent_flag_calls[distinct_id] << feature_flag_reported_key end - feature_flag_response + + FeatureFlagResult.from_value_and_payload(key, feature_flag_response, payload) end # Returns all flags for a given user @@ -342,6 +375,9 @@ def get_all_flags( # Returns payload for a given feature flag # + # @deprecated Use {#get_feature_flag_result} instead, which returns both the flag value and payload + # and properly raises the $feature_flag_called event. + # # @param [String] key The key of the feature flag # @param [String] distinct_id The distinct id of the user # @option [String or boolean] match_value The value of the feature flag to be matched diff --git a/lib/posthog/feature_flag_result.rb b/lib/posthog/feature_flag_result.rb new file mode 100644 index 0000000..1e38062 --- /dev/null +++ b/lib/posthog/feature_flag_result.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'json' + +module PostHog + # Represents the result of a feature flag evaluation + # containing both the flag value and payload + class FeatureFlagResult + attr_reader :key, :variant, :payload + + def initialize(key:, enabled:, variant: nil, payload: nil) + @key = key + @enabled = enabled + @variant = variant + @payload = payload + end + + # Returns the effective value of the feature flag + # variant if present, otherwise enabled status + def value + @variant || @enabled + end + + # Returns whether or not the feature flag evaluated as enabled + def enabled? + @enabled + end + + # Factory method to create from flag value and payload + def self.from_value_and_payload(key, value, payload) + return nil if value.nil? + + parsed_payload = parse_payload(payload) + + if value.is_a?(String) + new(key: key, enabled: true, variant: value, payload: parsed_payload) + else + new(key: key, enabled: value, payload: parsed_payload) + end + end + + def self.parse_payload(payload) + return nil if payload.nil? + return payload unless payload.is_a?(String) + return nil if payload.empty? + + begin + JSON.parse(payload) + rescue JSON::ParserError + payload + end + end + + private_class_method :parse_payload + end +end diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index 119b928..ca76c0b 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -169,18 +169,13 @@ def get_feature_flag( end response = nil - feature_flag = nil - - @feature_flags.each do |flag| - if key == flag[:key] - feature_flag = flag - break - end - end + payload = nil + feature_flag = @feature_flags_by_key&.[](key) unless feature_flag.nil? begin response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties) + payload = _compute_flag_payload_locally(key, response) unless response.nil? logger.debug "Successfully computed flag locally: #{key} -> #{response}" rescue RequiresServerEvaluation, InconclusiveMatchError => e logger.debug "Failed to compute flag #{key} locally: #{e}" @@ -202,11 +197,13 @@ def get_feature_flag( only_evaluate_locally, true) if flags_data.key?(:featureFlags) flags = stringify_keys(flags_data[:featureFlags] || {}) + payloads = stringify_keys(flags_data[:featureFlagPayloads] || {}) request_id = flags_data[:requestId] evaluated_at = flags_data[:evaluatedAt] else logger.debug "Missing feature flags key: #{flags_data.to_json}" flags = {} + payloads = {} end status = flags_data[:status] @@ -217,6 +214,7 @@ def get_feature_flag( response = flags[key] response = false if response.nil? + payload = payloads[key] feature_flag_error = errors.join(',') unless errors.empty? logger.debug "Successfully computed flag remotely: #{key} -> #{response}" @@ -232,7 +230,7 @@ def get_feature_flag( end end - [response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error] + [response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload] end def get_all_flags( diff --git a/spec/posthog/feature_flag_result_spec.rb b/spec/posthog/feature_flag_result_spec.rb new file mode 100644 index 0000000..ede2b38 --- /dev/null +++ b/spec/posthog/feature_flag_result_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'posthog/feature_flag_result' + +module PostHog + describe FeatureFlagResult do + describe '#initialize' do + it 'initializes with all attributes' do + result = FeatureFlagResult.new( + key: 'test-flag', + enabled: true, + variant: 'control', + payload: { 'foo' => 'bar' } + ) + + expect(result.key).to eq('test-flag') + expect(result.enabled?).to be true + expect(result.variant).to eq('control') + expect(result.payload).to eq({ 'foo' => 'bar' }) + end + + it 'initializes with minimal attributes' do + result = FeatureFlagResult.new(key: 'test-flag', enabled: false) + + expect(result.key).to eq('test-flag') + expect(result.enabled?).to be false + expect(result.variant).to be_nil + expect(result.payload).to be_nil + end + end + + describe '#value' do + it 'returns variant when present' do + result = FeatureFlagResult.new(key: 'test-flag', enabled: true, variant: 'control') + + expect(result.value).to eq('control') + end + + it 'returns enabled when variant is not present' do + result = FeatureFlagResult.new(key: 'test-flag', enabled: true) + + expect(result.value).to be true + end + + it 'returns false when flag is disabled and no variant' do + result = FeatureFlagResult.new(key: 'test-flag', enabled: false) + + expect(result.value).to be false + end + end + + describe '.from_value_and_payload' do + context 'with nil value' do + it 'returns nil' do + result = FeatureFlagResult.from_value_and_payload('test-flag', nil, nil) + + expect(result).to be_nil + end + end + + context 'with boolean true value' do + it 'creates result with enabled true and no variant' do + result = FeatureFlagResult.from_value_and_payload('test-flag', true, nil) + + expect(result.key).to eq('test-flag') + expect(result.enabled?).to be true + expect(result.variant).to be_nil + expect(result.payload).to be_nil + end + end + + context 'with boolean false value' do + it 'creates result with enabled false and no variant' do + result = FeatureFlagResult.from_value_and_payload('test-flag', false, nil) + + expect(result.key).to eq('test-flag') + expect(result.enabled?).to be false + expect(result.variant).to be_nil + expect(result.payload).to be_nil + end + end + + context 'with string variant' do + it 'creates result with enabled true and variant set' do + result = FeatureFlagResult.from_value_and_payload('test-flag', 'control', nil) + + expect(result.key).to eq('test-flag') + expect(result.enabled?).to be true + expect(result.variant).to eq('control') + expect(result.payload).to be_nil + end + end + + context 'with JSON string payload' do + it 'parses the JSON payload' do + result = FeatureFlagResult.from_value_and_payload('test-flag', true, '{"foo": "bar"}') + + expect(result.payload).to eq({ 'foo' => 'bar' }) + end + + it 'parses complex nested JSON payload' do + json_payload = '{"settings": {"theme": "dark", "notifications": true}, "features": ["a", "b"]}' + result = FeatureFlagResult.from_value_and_payload('test-flag', true, json_payload) + + expect(result.payload).to eq({ + 'settings' => { 'theme' => 'dark', 'notifications' => true }, + 'features' => %w[a b] + }) + end + end + + context 'with non-JSON string payload' do + it 'returns the string as-is' do + result = FeatureFlagResult.from_value_and_payload('test-flag', true, 'just a plain string') + + expect(result.payload).to eq('just a plain string') + end + end + + context 'with empty string payload' do + it 'returns nil' do + result = FeatureFlagResult.from_value_and_payload('test-flag', true, '') + + expect(result.payload).to be_nil + end + end + + context 'with hash payload' do + it 'passes through hash payloads unchanged' do + hash_payload = { 'foo' => 'bar' } + result = FeatureFlagResult.from_value_and_payload('test-flag', true, hash_payload) + + expect(result.payload).to eq({ 'foo' => 'bar' }) + end + end + + context 'with nil payload' do + it 'sets payload to nil' do + result = FeatureFlagResult.from_value_and_payload('test-flag', true, nil) + + expect(result.payload).to be_nil + end + end + + context 'with variant and payload' do + it 'creates result with variant and parsed payload' do + result = FeatureFlagResult.from_value_and_payload('test-flag', 'control', '{"discount": 10}') + + expect(result.key).to eq('test-flag') + expect(result.enabled?).to be true + expect(result.variant).to eq('control') + expect(result.payload).to eq({ 'discount' => 10 }) + expect(result.value).to eq('control') + end + end + end + end +end diff --git a/spec/posthog/feature_flag_spec.rb b/spec/posthog/feature_flag_spec.rb index a0c7f7c..60f83bd 100644 --- a/spec/posthog/feature_flag_spec.rb +++ b/spec/posthog/feature_flag_spec.rb @@ -4521,4 +4521,256 @@ module PostHog expect(result).to eq('{"message": "from-api"}') end end + + describe 'get_feature_flag_result' do + let(:flags_endpoint) { 'https://app.posthog.com/flags/?v=2' } + + it 'returns a FeatureFlagResult with flag value and payload' do + api_feature_flag_res = { + 'flags' => [ + { + 'id' => 1, + 'name' => 'Beta Feature', + 'key' => 'test-flag', + 'active' => true, + 'filters' => { + 'groups' => [{ 'rollout_percentage' => 100 }], + 'payloads' => { 'true' => '{"discount": 10}' } + } + } + ] + } + stub_request( + :get, + 'https://app.posthog.com/api/feature_flag/local_evaluation?token=testsecret&send_cohorts=true' + ).to_return(status: 200, body: api_feature_flag_res.to_json) + + stub_request(:post, flags_endpoint).to_return(status: 400) + + c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + + result = c.get_feature_flag_result('test-flag', 'some-distinct-id') + + expect(result).to be_a(FeatureFlagResult) + expect(result.key).to eq('test-flag') + expect(result.enabled?).to be true + expect(result.variant).to be_nil + expect(result.payload).to eq({ 'discount' => 10 }) + expect(result.value).to be true + end + + it 'returns a FeatureFlagResult with variant and payload' do + multivariate_flag = { + 'id' => 1, + 'name' => 'Multivariate Feature', + 'key' => 'multivariate-flag', + 'active' => true, + 'filters' => { + 'groups' => [{ 'rollout_percentage' => 100 }], + 'multivariate' => { + 'variants' => [ + { 'key' => 'control', 'rollout_percentage' => 100 } + ] + }, + 'payloads' => { 'control' => '{"theme": "dark"}' } + } + } + stub_request( + :get, + 'https://app.posthog.com/api/feature_flag/local_evaluation?token=testsecret&send_cohorts=true' + ).to_return(status: 200, body: { 'flags' => [multivariate_flag] }.to_json) + + stub_request(:post, flags_endpoint).to_return(status: 400) + + c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + + result = c.get_feature_flag_result('multivariate-flag', 'some-distinct-id') + + expect(result).to be_a(FeatureFlagResult) + expect(result.key).to eq('multivariate-flag') + expect(result.enabled?).to be true + expect(result.variant).to eq('control') + expect(result.payload).to eq({ 'theme' => 'dark' }) + expect(result.value).to eq('control') + end + + it 'raises $feature_flag_called event' do + api_feature_flag_res = { + 'flags' => [ + { + 'id' => 1, + 'name' => 'Beta Feature', + 'key' => 'test-flag', + 'active' => true, + 'filters' => { + 'groups' => [{ 'rollout_percentage' => 100 }], + 'payloads' => { 'true' => '{"discount": 10}' } + } + } + ] + } + stub_request( + :get, + 'https://app.posthog.com/api/feature_flag/local_evaluation?token=testsecret&send_cohorts=true' + ).to_return(status: 200, body: api_feature_flag_res.to_json) + + stub_request(:post, flags_endpoint).to_return(status: 400) + stub_const('PostHog::VERSION', '2.8.0') + + c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + + c.get_feature_flag_result('test-flag', 'some-distinct-id') + + captured_message = c.dequeue_last_message + expect(captured_message[:event]).to eq('$feature_flag_called') + expect(captured_message[:properties]['$feature_flag']).to eq('test-flag') + expect(captured_message[:properties]['$feature_flag_response']).to be true + expect(captured_message[:properties]['locally_evaluated']).to be true + end + + it 'does not raise event when send_feature_flag_events is false' do + api_feature_flag_res = { + 'flags' => [ + { + 'id' => 1, + 'name' => 'Beta Feature', + 'key' => 'test-flag', + 'active' => true, + 'filters' => { + 'groups' => [{ 'rollout_percentage' => 100 }], + 'payloads' => { 'true' => '{"discount": 10}' } + } + } + ] + } + stub_request( + :get, + 'https://app.posthog.com/api/feature_flag/local_evaluation?token=testsecret&send_cohorts=true' + ).to_return(status: 200, body: api_feature_flag_res.to_json) + + stub_request(:post, flags_endpoint).to_return(status: 400) + + c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + + result = c.get_feature_flag_result('test-flag', 'some-distinct-id', send_feature_flag_events: false) + + expect(result).to be_a(FeatureFlagResult) + expect(result.value).to be true + expect(c.queued_messages).to eq(0) + end + + it 'only sends event once per distinct_id and flag combo' do + api_feature_flag_res = { + 'flags' => [ + { + 'id' => 1, + 'name' => 'Beta Feature', + 'key' => 'test-flag', + 'active' => true, + 'filters' => { + 'groups' => [{ 'rollout_percentage' => 100 }], + 'payloads' => { 'true' => '{"discount": 10}' } + } + } + ] + } + stub_request( + :get, + 'https://app.posthog.com/api/feature_flag/local_evaluation?token=testsecret&send_cohorts=true' + ).to_return(status: 200, body: api_feature_flag_res.to_json) + + stub_request(:post, flags_endpoint).to_return(status: 400) + + c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + + # First call should send event + c.get_feature_flag_result('test-flag', 'some-distinct-id') + expect(c.queued_messages).to eq(1) + + # Second call with same distinct_id should not send event + c.get_feature_flag_result('test-flag', 'some-distinct-id') + expect(c.queued_messages).to eq(1) + + # Call with different distinct_id should send event + c.get_feature_flag_result('test-flag', 'other-distinct-id') + expect(c.queued_messages).to eq(2) + end + + it 'returns nil when flag evaluation returns nil' do + stub_request( + :get, + 'https://app.posthog.com/api/feature_flag/local_evaluation?token=testsecret&send_cohorts=true' + ).to_return(status: 200, body: { 'flags' => [] }.to_json) + + stub_request(:post, flags_endpoint) + .to_return(status: 200, body: { 'featureFlags' => {} }.to_json) + + c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + + result = c.get_feature_flag_result('non-existent-flag', 'some-distinct-id', only_evaluate_locally: true) + + expect(result).to be_nil + end + + it 'falls back to remote evaluation when needed' do + stub_request( + :get, + 'https://app.posthog.com/api/feature_flag/local_evaluation?token=testsecret&send_cohorts=true' + ).to_return(status: 200, body: { 'flags' => [] }.to_json) + + stub_request(:post, flags_endpoint) + .to_return( + status: 200, + body: { + 'featureFlags' => { 'remote-flag' => 'variant-a' }, + 'featureFlagPayloads' => { 'remote-flag' => '{"remote": "payload"}' }, + 'requestId' => 'test-request-id', + 'evaluatedAt' => 1_704_067_200_000 + }.to_json + ) + + c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + + result = c.get_feature_flag_result('remote-flag', 'some-distinct-id') + + expect(result).to be_a(FeatureFlagResult) + expect(result.key).to eq('remote-flag') + expect(result.enabled?).to be true + expect(result.variant).to eq('variant-a') + expect(result.payload).to eq({ 'remote' => 'payload' }) + expect(result.value).to eq('variant-a') + end + + it 'includes request_id and evaluated_at from remote evaluation in event' do + stub_request( + :get, + 'https://app.posthog.com/api/feature_flag/local_evaluation?token=testsecret&send_cohorts=true' + ).to_return(status: 200, body: { 'flags' => [] }.to_json) + + stub_request(:post, flags_endpoint) + .to_return( + status: 200, + body: { + 'featureFlags' => { 'remote-flag' => true }, + 'featureFlagPayloads' => { 'remote-flag' => '{"test": 123}' }, + 'requestId' => 'test-request-id', + 'evaluatedAt' => 1_704_067_200_000 + }.to_json + ) + + stub_const('PostHog::VERSION', '2.8.0') + + c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + + c.get_feature_flag_result('remote-flag', 'some-distinct-id') + + captured_message = c.dequeue_last_message + expect(captured_message[:event]).to eq('$feature_flag_called') + expect(captured_message[:properties]['$feature_flag']).to eq('remote-flag') + expect(captured_message[:properties]['$feature_flag_response']).to be true + expect(captured_message[:properties]['$feature_flag_request_id']).to eq('test-request-id') + expect(captured_message[:properties]['$feature_flag_evaluated_at']).to eq(1_704_067_200_000) + expect(captured_message[:properties]['locally_evaluated']).to be false + end + end end