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
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.0.6
3.3.10
65 changes: 34 additions & 31 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ PATH
GEM
remote: https://rubygems.org/
specs:
ast (2.4.2)
ast (2.4.3)
base64 (0.3.0)
byebug (11.1.3)
connection_pool (2.5.5)
diff-lcs (1.5.0)
faraday (2.7.4)
diff-lcs (1.6.2)
faraday (2.8.1)
base64
faraday-net_http (>= 2.0, < 3.1)
ruby2_keywords (>= 0.0.4)
faraday-net_http (3.0.2)
Expand All @@ -25,50 +26,52 @@ GEM
net-http-persistent (>= 4.0.4, < 5)
faraday-retry (2.4.0)
faraday (~> 2.0)
io-console (0.5.6)
irb (1.2.6)
reline (>= 0.1.5)
json (2.6.2)
io-console (0.8.2)
irb (1.6.3)
reline (>= 0.3.0)
json (2.7.6)
murmurhash3 (0.1.7)
net-http-persistent (4.0.8)
connection_pool (>= 2.2.4, < 4)
parallel (1.22.1)
parser (3.1.2.0)
parallel (1.24.0)
parser (3.3.10.1)
ast (~> 2.4.1)
racc
racc (1.8.1)
rainbow (3.1.1)
rake (13.0.6)
regexp_parser (2.5.0)
reline (0.1.5)
rake (13.3.1)
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
rspec (3.11.0)
rspec-core (~> 3.11.0)
rspec-expectations (~> 3.11.0)
rspec-mocks (~> 3.11.0)
rspec-core (3.11.0)
rspec-support (~> 3.11.0)
rspec-expectations (3.11.0)
rspec (3.13.2)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0)
rspec-mocks (3.11.1)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0)
rspec-support (3.11.0)
rubocop (1.33.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.6)
rubocop (1.50.2)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 3.1.0.0)
parser (>= 3.2.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.19.1, < 2.0)
rubocop-ast (>= 1.28.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.19.1)
parser (>= 3.1.1.0)
ruby-progressbar (1.11.0)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.30.0)
parser (>= 3.2.1.0)
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
unicode-display_width (2.2.0)
unicode-display_width (2.6.0)

PLATFORMS
ruby
Expand Down
36 changes: 32 additions & 4 deletions lib/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def initialize(clock, config, data_future, data_provider,
@hashed_units = {}
@pending_count = 0
@exposures ||= []
@attrs_seq = 0

set_units(config.units) if config.units
set_attributes(config.attributes) if config.attributes
Expand Down Expand Up @@ -137,6 +138,7 @@ def set_attribute(name, value)
check_not_closed?

@attributes.push(Attribute.new(name, value, @clock.to_i))
@attrs_seq += 1
end

def set_attributes(attributes)
Expand Down Expand Up @@ -184,6 +186,8 @@ def peek_treatment(experiment_name)
assignment(experiment_name).variant
end

alias peek peek_treatment

def variable_keys
check_ready?(true)

Expand Down Expand Up @@ -375,6 +379,26 @@ def experiment_matches(experiment, assignment)
experiment.traffic_split == assignment.traffic_split
end

def audience_matches(experiment, assignment)
if !experiment.audience.nil? && experiment.audience.size > 0
if @attrs_seq > (assignment.attrs_seq || 0)
attrs = @attributes.inject({}) do |hash, attr|
hash[attr.name] = attr.value
hash
end
match = @audience_matcher.evaluate(experiment.audience, attrs)
new_audience_mismatch = match && !match.result

if new_audience_mismatch != assignment.audience_mismatch
return false
end

assignment.attrs_seq = @attrs_seq
end
end
true
end

def assignment(experiment_name)
assignment = @assignment_cache[experiment_name.to_s]

Expand All @@ -391,7 +415,9 @@ def assignment(experiment_name)
return assignment
end
elsif custom.nil? || custom == assignment.variant
return assignment if experiment_matches(experiment.data, assignment)
if experiment_matches(experiment.data, assignment) && audience_matches(experiment.data, assignment)
return assignment
end
end
end

Expand Down Expand Up @@ -461,10 +487,11 @@ def assignment(experiment_name)
assignment.iteration = experiment.data.iteration
assignment.traffic_split = experiment.data.traffic_split
assignment.full_on_variant = experiment.data.full_on_variant
assignment.attrs_seq = @attrs_seq
end
end

if !experiment.nil? && assignment.variant < experiment.data.variants.length
if !experiment.nil? && assignment.variant >= 0 && assignment.variant < experiment.data.variants.length
assignment.variables = experiment.variables[assignment.variant] || {}
end

Expand Down Expand Up @@ -529,7 +556,7 @@ def assign_data(data)
value.value = @variable_parser.parse(self, experiment.name, custom_field_value.name, custom_value)

elsif custom_field_value.type.start_with?("boolean")
value.value = custom_value.to_bool
value.value = custom_value == "true"
Comment on lines 558 to +559
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the actual code at the specified lines in lib/context.rb
cat -n lib/context.rb | sed -n '550,570p'

Repository: absmartly/ruby-sdk

Length of output: 986


🏁 Script executed:

# Search for custom_field_value usage patterns and payloads
rg -n "custom_field_value|custom_value" lib/context.rb -B 5 -A 5 | head -100

Repository: absmartly/ruby-sdk

Length of output: 1900


🏁 Script executed:

# Look for any test files that might show payload examples
fd -t f '\.(test|spec)\.rb$|fixtures' -x rg -l "custom_field_value|custom.*boolean" {}

Repository: absmartly/ruby-sdk

Length of output: 44


🏁 Script executed:

# Search for schema or API documentation about custom fields
rg -n "boolean.*custom|custom.*boolean" -g '*.{rb,md,json,yml,yaml}' -B 2 -A 2

Repository: absmartly/ruby-sdk

Length of output: 399


🏁 Script executed:

# Search for test files and fixtures that might show payload structure
find . -type f \( -name '*test*.rb' -o -name '*spec*.rb' -o -name '*fixture*' \) | head -20

Repository: absmartly/ruby-sdk

Length of output: 846


🏁 Script executed:

# Look for JSON parsing or deserialization of custom_field_values
rg -n "custom_field" -A 10 -B 2 | grep -E "parse|json|deserialize|from|struct" -i | head -30

Repository: absmartly/ruby-sdk

Length of output: 2672


🏁 Script executed:

# Search for example payloads in README or documentation
fd -t f '\.(md|txt|json)$' -x rg -l "custom_field" {}

Repository: absmartly/ruby-sdk

Length of output: 44


🏁 Script executed:

# Look for the definition/model of custom_field_value
rg -n "class.*CustomField|attr.*custom_field" -g '*.rb' | head -20

Repository: absmartly/ruby-sdk

Length of output: 167


🏁 Script executed:

# Read the CustomFieldValue class definition
cat -n lib/json/custom_field_value.rb

Repository: absmartly/ruby-sdk

Length of output: 882


🏁 Script executed:

# Search for boolean custom_field examples in tests
rg -n "boolean" spec/ -B 3 -A 3

Repository: absmartly/ruby-sdk

Length of output: 15920


🏁 Script executed:

# Search for CustomFieldValue initialization with boolean type
rg -n "CustomFieldValue.*boolean|boolean.*CustomFieldValue" spec/

Repository: absmartly/ruby-sdk

Length of output: 44


🏁 Script executed:

# Look at the full context spec to see how boolean is used
rg -n "custom_field.*boolean|boolean.*custom_field" -A 5 -B 5 spec/context_spec.rb

Repository: absmartly/ruby-sdk

Length of output: 44


🏁 Script executed:

# Search for DefaultContextDataDeserializer implementation to see how JSON is parsed
rg -n "class DefaultContextDataDeserializer|custom_field" lib/ -A 20 | head -80

Repository: absmartly/ruby-sdk

Length of output: 3513


🏁 Script executed:

# Look for JSON parsing or deserialize_custom_field methods
rg -n "deserialize|parse.*custom_field|json.*custom" lib/ -i

Repository: absmartly/ruby-sdk

Length of output: 3195


🏁 Script executed:

# Check if there's any README or API documentation showing custom_field_value structure
fd -t f '\.(md|txt|json)$' | xargs grep -l "custom_field\|customField" 2>/dev/null

Repository: absmartly/ruby-sdk

Length of output: 98


🏁 Script executed:

# Search for how values are actually assigned - look for JSON.parse calls
rg -n "JSON.parse|JSON.generate|\.to_h|from_json" lib/ -B 2 -A 2 | head -50

Repository: absmartly/ruby-sdk

Length of output: 1388


🏁 Script executed:

# Check the actual fixture payload to see how custom_field_values are structured
cat -n spec/fixtures/resources/context.json | head -150

Repository: absmartly/ruby-sdk

Length of output: 3519


🏁 Script executed:

# Search for any integration tests that might show actual API payload
rg -n "custom_field" spec/ --type json

Repository: absmartly/ruby-sdk

Length of output: 44


Handle boolean custom field values that arrive as actual booleans.

When the API sends a boolean value (e.g., true or false) in JSON, JSON.parse preserves the type, converting it to a Ruby boolean rather than a string. The current comparison custom_value == "true" will fail: true == "true" evaluates to false, breaking the logic for actual boolean values.

Suggested fix
-                  value.value = custom_value == "true"
+                  value.value = (custom_value == true) || (custom_value.to_s.downcase == "true")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
elsif custom_field_value.type.start_with?("boolean")
value.value = custom_value.to_bool
value.value = custom_value == "true"
elsif custom_field_value.type.start_with?("boolean")
value.value = (custom_value == true) || (custom_value.to_s.downcase == "true")
🤖 Prompt for AI Agents
In `@lib/context.rb` around lines 558 - 559, The boolean branch using
custom_field_value.type.start_with?("boolean") currently sets value.value =
custom_value == "true", which fails when custom_value is already a Ruby boolean;
update the assignment to correctly handle actual booleans and string booleans by
detecting the incoming type (e.g., TrueClass/FalseClass) and returning it
directly, and for string values normalize (downcase) and compare to "true";
ensure falsy/other types are coerced to false as appropriate so value.value
becomes a proper boolean in all cases.


elsif custom_field_value.type.start_with?("number")
value.value = custom_value.to_i
Expand Down Expand Up @@ -603,7 +630,7 @@ def log_error(error)
class Assignment
attr_accessor :id, :iteration, :full_on_variant, :name, :unit_type,
:traffic_split, :variant, :assigned, :overridden, :eligible,
:full_on, :custom, :audience_mismatch, :variables, :exposed
:full_on, :custom, :audience_mismatch, :variables, :exposed, :attrs_seq

def initialize
@variant = 0
Expand All @@ -616,6 +643,7 @@ def initialize
@full_on = false
@custom = false
@audience_mismatch = false
@attrs_seq = 0
end
end

Expand Down
122 changes: 122 additions & 0 deletions spec/context_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,38 @@ def faraday_response(content)
expect(context.peek_treatment("exp_test_ab")).to eq 0
end

it "peek_treatmentReEvaluatesAudienceWhenAttributesChangeInStrictMode" do
context = create_context(audience_strict_data_future_ready)

expect(context.peek_treatment("exp_test_ab")).to eq 0

context.set_attribute("age", 25)

expect(context.peek_treatment("exp_test_ab")).to eq 1
expect(context.pending_count).to eq 0
end

it "peek_treatmentReEvaluatesAudienceWhenAttributesChangeInNonStrictMode" do
context = create_context(audience_data_future_ready)

expect(context.peek_treatment("exp_test_ab")).to eq 1

context.set_attribute("age", 25)

expect(context.peek_treatment("exp_test_ab")).to eq 1
expect(context.pending_count).to eq 0
end

it "peek_treatmentDoesNotReEvaluateAudienceWhenNoNewAttributesSet" do
context = create_context(audience_strict_data_future_ready)

context.set_attribute("age", 15)

expect(context.peek_treatment("exp_test_ab")).to eq 0
expect(context.peek_treatment("exp_test_ab")).to eq 0
expect(context.pending_count).to eq 0
end

it "treatment" do
context = create_ready_context(evt_handler: event_handler)

Expand Down Expand Up @@ -973,6 +1005,96 @@ def faraday_response(content)
expect(event_handler).to have_received(:publish).with(context, expected).once
end

it "treatmentReEvaluatesAudienceWhenAttributesChangeInStrictMode" do
context = create_context(audience_strict_data_future_ready)

expect(context.treatment("exp_test_ab")).to eq(0)
expect(context.pending_count).to eq(1)

context.set_attribute("age", 25)

expect(context.treatment("exp_test_ab")).to eq(1)
expect(context.pending_count).to eq(2)
end

it "treatmentReEvaluatesAudienceWhenAttributesChangeInNonStrictMode" do
context = create_context(audience_data_future_ready)

expect(context.treatment("exp_test_ab")).to eq(1)
expect(context.pending_count).to eq(1)

context.set_attribute("age", 25)

expect(context.treatment("exp_test_ab")).to eq(1)
expect(context.pending_count).to eq(2)
end

it "treatmentDoesNotReEvaluateAudienceWhenNoNewAttributesSet" do
context = create_context(audience_strict_data_future_ready)

context.set_attribute("age", 15)

expect(context.treatment("exp_test_ab")).to eq(0)
expect(context.pending_count).to eq(1)

expect(context.treatment("exp_test_ab")).to eq(0)
expect(context.pending_count).to eq(1)
end

it "treatmentDoesNotReEvaluateAudienceForExperimentsWithoutAudienceFilter" do
context = create_ready_context

expect(context.treatment("exp_test_abc")).to eq(2)
expect(context.pending_count).to eq(1)

context.set_attribute("age", 25)

expect(context.treatment("exp_test_abc")).to eq(2)
expect(context.pending_count).to eq(1)
end

it "treatmentReEvaluatesFromAudienceMismatchToMatchInStrictMode" do
context = create_context(audience_strict_data_future_ready, evt_handler: event_handler)

expect(context.treatment("exp_test_ab")).to eq(0)
expect(context.pending_count).to eq(1)

allow(event_handler).to receive(:publish).and_return(publish_future)
context.publish

expected = PublishEvent.new
expected.hashed = true
expected.published_at = clock_in_millis
expected.units = publish_units

expected.exposures = [
Exposure.new(1, "exp_test_ab", "session_id", 0, clock_in_millis, false, true, false, false, false, true)
]

expect(event_handler).to have_received(:publish).with(context, expected).once

context.set_attribute("age", 30)

expect(context.treatment("exp_test_ab")).to eq(1)
expect(context.pending_count).to eq(1)

context.publish

expected2 = PublishEvent.new
expected2.hashed = true
expected2.published_at = clock_in_millis
expected2.units = publish_units
expected2.attributes = [
Attribute.new("age", 30, clock_in_millis)
]

expected2.exposures = [
Exposure.new(1, "exp_test_ab", "session_id", 1, clock_in_millis, true, true, false, false, false, false)
]

expect(event_handler).to have_received(:publish).with(context, expected2).once
end

it "treatmentCallsEventLogger" do
event_logger.clear
context = create_ready_context
Expand Down
39 changes: 39 additions & 0 deletions spec/json_expr/operators/in_operator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,44 @@
expect(evaluator).to have_received(:compare).with(haystack, 2).once
end
end

it "test object contains key" do
haystackab = { "a" => 1, "b" => 2 }
haystackbc = { "b" => 2, "c" => 3, "0" => 100 }

expect(operator.evaluate(evaluator, [haystackab, "c"])).to be_falsey
expect(evaluator).to have_received(:evaluate).twice
expect(evaluator).to have_received(:evaluate).with(haystackab).once
expect(evaluator).to have_received(:evaluate).with("c").once
expect(evaluator).to have_received(:string_convert).with("c").once

reset_evaluator
expect(operator.evaluate(evaluator, [haystackbc, "a"])).to be_falsey
expect(evaluator).to have_received(:evaluate).twice
expect(evaluator).to have_received(:evaluate).with(haystackbc).once
expect(evaluator).to have_received(:evaluate).with("a").once
expect(evaluator).to have_received(:string_convert).with("a").once

reset_evaluator
expect(operator.evaluate(evaluator, [haystackbc, "b"])).to be_truthy
expect(evaluator).to have_received(:evaluate).twice
expect(evaluator).to have_received(:evaluate).with(haystackbc).once
expect(evaluator).to have_received(:evaluate).with("b").once
expect(evaluator).to have_received(:string_convert).with("b").once

reset_evaluator
expect(operator.evaluate(evaluator, [haystackbc, "c"])).to be_truthy
expect(evaluator).to have_received(:evaluate).twice
expect(evaluator).to have_received(:evaluate).with(haystackbc).once
expect(evaluator).to have_received(:evaluate).with("c").once
expect(evaluator).to have_received(:string_convert).with("c").once

reset_evaluator
expect(operator.evaluate(evaluator, [haystackbc, 0])).to be_truthy
expect(evaluator).to have_received(:evaluate).twice
expect(evaluator).to have_received(:evaluate).with(haystackbc).once
expect(evaluator).to have_received(:evaluate).with(0).once
expect(evaluator).to have_received(:string_convert).with(0).once
end
end
end