diff --git a/lib/amplitude-experiment.rb b/lib/amplitude-experiment.rb index 60db186..2dfe9f0 100644 --- a/lib/amplitude-experiment.rb +++ b/lib/amplitude-experiment.rb @@ -6,6 +6,7 @@ require 'experiment/variant' require 'experiment/factory' require 'experiment/remote/client' +require 'experiment/remote/fetch_options' require 'experiment/local/client' require 'experiment/local/config' require 'experiment/local/assignment/assignment' diff --git a/lib/experiment/remote/client.rb b/lib/experiment/remote/client.rb index 18af547..f646887 100644 --- a/lib/experiment/remote/client.rb +++ b/lib/experiment/remote/client.rb @@ -30,7 +30,7 @@ def initialize(api_key, config = nil) # @param [User] user # @return [Hash] Variants Hash def fetch(user) - AmplitudeExperiment.filter_default_variants(fetch_internal(user)) + AmplitudeExperiment.filter_default_variants(fetch_internal(user, nil)) rescue StandardError => e @logger.error("[Experiment] Failed to fetch variants: #{e.message}") {} @@ -41,9 +41,10 @@ def fetch(user) # This method will automatically retry if configured (default). This function differs from fetch as it will # return a default variant object if the flag was evaluated but the user was not assigned (i.e. off). # @param [User] user + # @param [FetchOptions] fetch_options # @return [Hash] Variants Hash - def fetch_v2(user) - fetch_internal(user) + def fetch_v2(user, fetch_options = nil) + fetch_internal(user, fetch_options) rescue StandardError => e @logger.error("[Experiment] Failed to fetch variants: #{e.message}") {} @@ -56,7 +57,7 @@ def fetch_v2(user) # @yield [User, Hash] callback block takes user object and variants hash def fetch_async(user, &callback) Thread.new do - variants = fetch_internal(user) + variants = AmplitudeExperiment.filter_default_variants(fetch_internal(user, nil)) yield(user, variants) unless callback.nil? variants rescue StandardError => e @@ -72,10 +73,10 @@ def fetch_async(user, &callback) # This method will automatically retry if configured (default). # @param [User] user # @yield [User, Hash] callback block takes user object and variants hash - def fetch_async_v2(user, &callback) + def fetch_async_v2(user, fetch_options = nil, &callback) Thread.new do - variants = fetch_internal(user) - yield(user, filter_default_variants(variants)) unless callback.nil? + variants = fetch_internal(user, fetch_options) + yield(user, variants) unless callback.nil? variants rescue StandardError => e @logger.error("[Experiment] Failed to fetch variants: #{e.message}") @@ -87,14 +88,15 @@ def fetch_async_v2(user, &callback) private # @param [User] user - def fetch_internal(user) + # @param [FetchOptions] fetch_options + def fetch_internal(user, fetch_options) @logger.debug("[Experiment] Fetching variants for user: #{user.as_json}") - do_fetch(user, @config.connect_timeout_millis, @config.fetch_timeout_millis) + do_fetch(user, fetch_options, @config.connect_timeout_millis, @config.fetch_timeout_millis) rescue StandardError => e @logger.error("[Experiment] Fetch failed: #{e.message}") if should_retry_fetch?(e) begin - retry_fetch(user) + retry_fetch(user, fetch_options) rescue StandardError => err @logger.error("[Experiment] Retry Fetch failed: #{err.message}") end @@ -103,7 +105,8 @@ def fetch_internal(user) end # @param [User] user - def retry_fetch(user) + # @param [FetchOptions] fetch_options + def retry_fetch(user, fetch_options) return {} if @config.fetch_retries.zero? @logger.debug('[Experiment] Retrying fetch') @@ -112,7 +115,7 @@ def retry_fetch(user) @config.fetch_retries.times do sleep(delay_millis.to_f / 1000.0) begin - return do_fetch(user, @config.connect_timeout_millis, @config.fetch_retry_timeout_millis) + return do_fetch(user, fetch_options, @config.connect_timeout_millis, @config.fetch_retry_timeout_millis) rescue StandardError => e @logger.error("[Experiment] Retry failed: #{e.message}") err = e @@ -123,15 +126,24 @@ def retry_fetch(user) end # @param [User] user + # @param [FetchOptions] fetch_options # @param [Integer] connect_timeout_millis # @param [Integer] fetch_timeout_millis - def do_fetch(user, connect_timeout_millis, fetch_timeout_millis) + def do_fetch(user, fetch_options, connect_timeout_millis, fetch_timeout_millis) start_time = Time.now user_context = add_context(user) headers = { 'Authorization' => "Api-Key #{@api_key}", 'Content-Type' => 'application/json;charset=utf-8' } + unless fetch_options.nil? + unless fetch_options.tracks_assignment.nil? + headers['X-Amp-Exp-Track'] = fetch_options.tracks_assignment ? 'track' : 'no-track' + end + unless fetch_options.tracks_exposure.nil? + headers['X-Amp-Exp-Exposure-Track'] = fetch_options.tracks_exposure ? 'track' : 'no-track' + end + end connect_timeout = connect_timeout_millis.to_f / 1000 if (connect_timeout_millis.to_f / 1000) > 0 read_timeout = fetch_timeout_millis.to_f / 1000 if (fetch_timeout_millis.to_f / 1000) > 0 http = PersistentHttpClient.get(@uri, { open_timeout: connect_timeout, read_timeout: read_timeout }, @api_key) diff --git a/lib/experiment/remote/fetch_options.rb b/lib/experiment/remote/fetch_options.rb new file mode 100644 index 0000000..2f0a729 --- /dev/null +++ b/lib/experiment/remote/fetch_options.rb @@ -0,0 +1,19 @@ +module AmplitudeExperiment + # Fetch options + class FetchOptions + # Whether to track assignment events. + # If not provided, the default is null, which will use server default (to track assignment events). + # @return [Boolean, nil] the value of tracks_assignment + attr_accessor :tracks_assignment + + # Whether to track exposure events. + # If not provided, the default is null, which will use server default (to not track exposure events). + # @return [Boolean, nil] the value of tracks_exposure + attr_accessor :tracks_exposure + + def initialize(tracks_assignment: nil, tracks_exposure: nil) + @tracks_assignment = tracks_assignment + @tracks_exposure = tracks_exposure + end + end +end diff --git a/spec/experiment/remote/client_spec.rb b/spec/experiment/remote/client_spec.rb index 005066b..4fd8053 100644 --- a/spec/experiment/remote/client_spec.rb +++ b/spec/experiment/remote/client_spec.rb @@ -93,10 +93,13 @@ def self.test_fetch_async_shared(response, test_user, variant_name, debug, expec stub_request(:post, server_url) .to_return(status: 200, body: response) client = RemoteEvaluationClient.new(api_key, RemoteEvaluationConfig.new(debug: debug)) + callback_called = false variants = client.fetch_async(test_user) do |user, block_variants| expect(user).to equal(test_user) expect(block_variants.fetch(variant_name)).to eq(expected_variant) + callback_called = true end + sleep 1 until callback_called expect(variants.key?(variant_name)).to be_truthy expect(variants.fetch(variant_name)).to eq(expected_variant) end @@ -175,6 +178,54 @@ def self.test_fetch_async_shared(response, test_user, variant_name, debug, expec expect { variants = client.fetch_v2(test_user) }.to output(/Retrying fetch/).to_stdout_from_any_process expect(variants).to eq({}) end + + it 'fetch v2 with fetch options' do + stub_request(:post, server_url) + .to_return(status: 200, body: response_with_key) + test_user = User.new(user_id: 'test_user') + client = RemoteEvaluationClient.new(api_key) + + WebMock.reset! + fetch_options = FetchOptions.new(tracks_assignment: true, tracks_exposure: true) + variants = client.fetch_v2(test_user, fetch_options) + expect(variants.key?(variant_name)).to be_truthy + expect(variants.fetch(variant_name)).to eq(Variant.new(key: 'on', payload: 'payload', value: 'on')) + + expect(a_request(:post, server_url).with(headers: { 'X-Amp-Exp-Track' => 'track', 'X-Amp-Exp-Exposure-Track' => 'track' })).to have_been_made.once + + WebMock.reset! + fetch_options = FetchOptions.new(tracks_assignment: false, tracks_exposure: false) + client.fetch_v2(test_user, fetch_options) + expect(a_request(:post, server_url).with(headers: { 'X-Amp-Exp-Track' => 'no-track', 'X-Amp-Exp-Exposure-Track' => 'no-track' })).to have_been_made.once + + WebMock.reset! + last_request = nil + WebMock.after_request { |request_signature, _response| last_request = request_signature } + fetch_options = FetchOptions.new + client.fetch_v2(test_user, fetch_options) + expect(a_request(:post, server_url)).to have_been_made.once + expect(last_request.headers.key?('X-Amp-Exp-Track')).to be_falsy + expect(last_request.headers.key?('X-Amp-Exp-Exposure-Track')).to be_falsy + end + end + + describe '#fetch_async_v2' do + it 'fetch async v2 with fetch options' do + stub_request(:post, server_url) + .to_return(status: 200, body: response_with_key) + test_user = User.new(user_id: 'test_user') + fetch_options = FetchOptions.new(tracks_assignment: true, tracks_exposure: true) + client = RemoteEvaluationClient.new(api_key, RemoteEvaluationConfig.new(debug: true)) + callback_called = false + client.fetch_async_v2(test_user, fetch_options) do |user, block_variants| + expect(user).to equal(test_user) + expect(block_variants.key?(variant_name)).to be_truthy + expect(block_variants.fetch(variant_name)).to eq(Variant.new(key: 'on', payload: 'payload')) + expect(a_request(:post, server_url).with(headers: { 'X-Amp-Exp-Track' => 'track', 'X-Amp-Exp-Exposure-Track' => 'track' })).to have_been_made.once + callback_called = true + end + sleep 1 until callback_called + end end end end