Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/amplitude-experiment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
38 changes: 25 additions & 13 deletions lib/experiment/remote/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
{}
Expand All @@ -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}")
{}
Expand All @@ -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
Expand All @@ -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}")
Expand All @@ -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
Expand All @@ -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')
Expand All @@ -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
Expand All @@ -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
Comment on lines +139 to +146
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The nested unless statements create unnecessary complexity. Consider simplifying by using early returns or guard clauses. For example: headers['X-Amp-Exp-Track'] = fetch_options.tracks_assignment ? 'track' : 'no-track' unless fetch_options&.tracks_assignment.nil? and headers['X-Amp-Exp-Exposure-Track'] = fetch_options.tracks_exposure ? 'track' : 'no-track' unless fetch_options&.tracks_exposure.nil?

Suggested change
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
headers['X-Amp-Exp-Track'] = fetch_options.tracks_assignment ? 'track' : 'no-track' unless fetch_options&.tracks_assignment.nil?
headers['X-Amp-Exp-Exposure-Track'] = fetch_options.tracks_exposure ? 'track' : 'no-track' unless fetch_options&.tracks_exposure.nil?

Copilot uses AI. Check for mistakes.
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)
Expand Down
17 changes: 17 additions & 0 deletions lib/experiment/remote/fetch_options.rb
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions spec/experiment/remote/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The busy-wait loop sleep 1 until callback_called can delay tests unnecessarily. Consider using a smaller sleep interval (e.g., sleep 0.01) or adding a timeout to prevent infinite loops if the callback is never called.

Suggested change
sleep 1 until callback_called
start_time = Time.now
timeout = 2 # seconds
until callback_called
sleep 0.01
if Time.now - start_time > timeout
raise "Timeout waiting for async callback"
end
end

Copilot uses AI. Check for mistakes.
expect(variants.key?(variant_name)).to be_truthy
expect(variants.fetch(variant_name)).to eq(expected_variant)
end
Expand Down Expand Up @@ -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'))
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expected variant is inconsistent with the mocked response. The response response_with_key is defined as '{\"sdk-ci-test\":{\"key\":\"on\",\"payload\":\"payload\"}}', and fetch_v2 methods return variants with both key and value populated from the response. However, the expected variant at line 223 only sets key and payload, while the test at line 192 correctly expects Variant.new(key: 'on', payload: 'payload', value: 'on'). Line 223 should include value: 'on' to match the actual behavior.

Suggested change
expect(block_variants.fetch(variant_name)).to eq(Variant.new(key: 'on', payload: 'payload'))
expect(block_variants.fetch(variant_name)).to eq(Variant.new(key: 'on', payload: 'payload', value: 'on'))

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The busy-wait loop sleep 1 until callback_called can delay tests unnecessarily. Consider using a smaller sleep interval (e.g., sleep 0.01) or adding a timeout to prevent infinite loops if the callback is never called.

Suggested change
sleep 1 until callback_called
timeout = 5 # seconds
start_time = Time.now
until callback_called
sleep 0.01
if Time.now - start_time > timeout
raise "Timeout waiting for callback to be called"
end
end

Copilot uses AI. Check for mistakes.
end
end
end
end
Loading