From 22853ccb36941f167ba8f63decc77c0dfd497c4f Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 27 Oct 2025 17:25:32 -0700 Subject: [PATCH 1/5] feat: add assignment and exposure events tracking options --- lib/amplitude-experiment.rb | 1 + lib/experiment/remote/client.rb | 36 ++++++++++++++------- lib/experiment/remote/fetch_options.rb | 17 ++++++++++ spec/experiment/remote/client_spec.rb | 43 ++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 lib/experiment/remote/fetch_options.rb 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..47bfce7 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 = fetch_internal(user, nil) yield(user, variants) unless callback.nil? variants rescue StandardError => e @@ -72,9 +73,9 @@ 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) + variants = fetch_internal(user, fetch_options) yield(user, filter_default_variants(variants)) unless callback.nil? variants rescue StandardError => e @@ -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..46cffb9 --- /dev/null +++ b/lib/experiment/remote/fetch_options.rb @@ -0,0 +1,17 @@ +module AmplitudeExperiment + # Fetch options + class FetchOptions + # Whether to track assignment events. + # @return [Boolean, nil] the value of tracks_assignment + attr_accessor :tracks_assignment + + # Whether to 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..8ee01ec 100644 --- a/spec/experiment/remote/client_spec.rb +++ b/spec/experiment/remote/client_spec.rb @@ -175,6 +175,49 @@ 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') + fetch_options = FetchOptions.new(tracks_assignment: true, tracks_exposure: true) + client = RemoteEvaluationClient.new(api_key, RemoteEvaluationConfig.new(debug: 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')) + + 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)) + 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 + end + end end end end From bc1c5a8cd86ca1b37dc24ff8f1b66dbcfac20f53 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 28 Oct 2025 10:00:26 -0700 Subject: [PATCH 2/5] test: fix possible flaky --- spec/experiment/remote/client_spec.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/experiment/remote/client_spec.rb b/spec/experiment/remote/client_spec.rb index 8ee01ec..232d419 100644 --- a/spec/experiment/remote/client_spec.rb +++ b/spec/experiment/remote/client_spec.rb @@ -180,11 +180,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_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) - client = RemoteEvaluationClient.new(api_key, RemoteEvaluationConfig.new(debug: 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')) + 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 From 52335bcb7dbe1207a7117769b3cab584d1431df8 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 28 Oct 2025 11:32:13 -0700 Subject: [PATCH 3/5] test: fix flakiness --- spec/experiment/remote/client_spec.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/experiment/remote/client_spec.rb b/spec/experiment/remote/client_spec.rb index 232d419..bd25d1b 100644 --- a/spec/experiment/remote/client_spec.rb +++ b/spec/experiment/remote/client_spec.rb @@ -213,12 +213,15 @@ def self.test_fetch_async_shared(response, test_user, variant_name, debug, expec 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 From fcce39131bd6a9c2f45d4d774c942e39108e681d Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 28 Oct 2025 11:36:45 -0700 Subject: [PATCH 4/5] fix: filter for async fetch v1 and v2 --- lib/experiment/remote/client.rb | 4 ++-- spec/experiment/remote/client_spec.rb | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/experiment/remote/client.rb b/lib/experiment/remote/client.rb index 47bfce7..f646887 100644 --- a/lib/experiment/remote/client.rb +++ b/lib/experiment/remote/client.rb @@ -57,7 +57,7 @@ def fetch_v2(user, fetch_options = nil) # @yield [User, Hash] callback block takes user object and variants hash def fetch_async(user, &callback) Thread.new do - variants = fetch_internal(user, nil) + variants = AmplitudeExperiment.filter_default_variants(fetch_internal(user, nil)) yield(user, variants) unless callback.nil? variants rescue StandardError => e @@ -76,7 +76,7 @@ def fetch_async(user, &callback) def fetch_async_v2(user, fetch_options = nil, &callback) Thread.new do variants = fetch_internal(user, fetch_options) - yield(user, filter_default_variants(variants)) unless callback.nil? + yield(user, variants) unless callback.nil? variants rescue StandardError => e @logger.error("[Experiment] Failed to fetch variants: #{e.message}") diff --git a/spec/experiment/remote/client_spec.rb b/spec/experiment/remote/client_spec.rb index bd25d1b..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 From ea5b6e023f00bf341edc0714843309e61619331b Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 19 Nov 2025 16:08:31 -0800 Subject: [PATCH 5/5] docs: add comments --- lib/experiment/remote/fetch_options.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/experiment/remote/fetch_options.rb b/lib/experiment/remote/fetch_options.rb index 46cffb9..2f0a729 100644 --- a/lib/experiment/remote/fetch_options.rb +++ b/lib/experiment/remote/fetch_options.rb @@ -2,10 +2,12 @@ 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