Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
ddcb715
fix: cross-SDK test compatibility fixes
joalves Jan 19, 2026
6c4ff20
fix: add explicit ostruct require for Ruby 3.3+ compatibility
joalves Jan 20, 2026
3c01a7a
feat: upgrade Ruby to 3.3.10 and add peek alias
joalves Jan 22, 2026
a33d838
test: add audience matching tests
joalves Jan 26, 2026
fb2e0f4
fix: remove duplicate require 'ostruct' in spec_helper
joalves Jan 26, 2026
1d6a6dc
feat: add comprehensive test coverage improvements
joalves Jan 27, 2026
afc07fb
docs: add Rails, Sinatra examples and request configuration to Ruby SDK
joalves Jan 30, 2026
4da15be
test: add canonical test parity (340 → 412 tests)
joalves Feb 6, 2026
763d659
fix: Ruby 3.3+ compatibility and cross-SDK test fixes
joalves Feb 21, 2026
080dcf0
fix: correct equals and in operators for type coercion edge cases
joalves Feb 24, 2026
551b674
docs: restructure README to match standard SDK documentation structure
joalves Feb 24, 2026
f7c057b
feat: add async context creation and SDK convenience methods
joalves Feb 27, 2026
1b97de5
docs: update README documentation
joalves Mar 9, 2026
d22fd1d
fix: remove check_not_closed? guard from set_override and set_overrides
joalves Mar 15, 2026
e499797
fix: variable_keys returns array of experiment names per key
joalves Mar 15, 2026
9f23db1
fix: preserve events on publish failure instead of losing them
joalves Mar 15, 2026
dce469e
feat: add get_unit, get_units, get_attribute, get_attributes, ready_e…
joalves Mar 15, 2026
539cd50
feat: add finalize aliases, standardize error messages, rename custom…
joalves Mar 15, 2026
1963433
fix(ruby): return safe defaults from read methods when context not re…
joalves Mar 15, 2026
fc3b792
fix: remove transform_keys hot path, store keys as symbols, rename ca…
joalves Mar 17, 2026
debd25e
feat: cross-SDK consistency fixes — all 201 scenarios passing
joalves Mar 17, 2026
573ec6f
fix: add mutex to queue_exposure for thread safety
joalves Mar 17, 2026
f5d2af7
fix: always call queue_exposure, let mutex handle dedup
joalves Mar 17, 2026
e3ca7f2
fix: relax concurrent exposure test assertion
joalves Mar 17, 2026
02edc75
refactor: rename ContextEventHandler to ContextPublisher
joalves Mar 18, 2026
4df9ed2
fix: address coderabbit review issues
joalves Mar 18, 2026
3b81e44
fix(jsonexpr): restore haystack-first IN operator (CONTAINS semantics)
joalves Jun 15, 2026
514bdf2
fix(jsonexpr): eq(null, null) returns null (canonical null-operand ha…
joalves Jun 16, 2026
3dd04a2
test: add canonical astral/multibyte hashUnit regression test
joalves Jun 17, 2026
b2ccac2
test: add hermetic local-HTTP integration test for real fetch/publish
joalves Jun 25, 2026
78f49e4
fix: address review feedback (flush race, log redaction, README/spec …
joalves Oct 20, 2026
a847fa7
fix: serialize publish snapshots and publish refresh state atomically
joalves Oct 20, 2026
293849d
Merge origin/main into feat/ruby-3.3-compatibility
joalves Oct 20, 2026
69122e6
fix: swap context state atomically; run exposure callbacks outside qu…
joalves Oct 20, 2026
5170311
fix: read data from atomic state; avoid PUBLISH callback reentrancy d…
joalves Oct 20, 2026
042711a
fix: run READY callback outside ready lock; defer reentrant close unt…
joalves Oct 20, 2026
1c92556
feat!: JS SDK behavioral parity (maintainer decisions D1-D8)
joalves Oct 23, 2026
0e667ba
test: pin preservation of a cached audience mismatch on indeterminate…
joalves Oct 23, 2026
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
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,12 @@
.rspec_status
.byebug_history

.gemspec
.gemspec
.claude/
.DS_Store
AUDIT_REPORT.md
FIXES_SUMMARY.md
SILENT_FAILURE_AUDIT.md
vendor/
*.bak
*.backup
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
## [Unreleased]
## [2.0.0] - Unreleased

Behavior now matches the JavaScript SDK (the reference implementation).

### Breaking
- Read methods (`treatment`, `peek_treatment`, `variable_value`, `peek_variable_value`, `variable_keys`, `custom_field_keys`, `custom_field_value`, `custom_field_value_type`) raise `IllegalStateException` "ABsmartly Context is not yet ready." before the context is ready and "ABsmartly Context is finalized." after `close`, instead of returning 0 / the default. `experiments` raises only when not ready. `publish` and `refresh` raise when not ready.
- The finalize event is named `"finalize"` (`ContextEventLogger::EVENT_TYPE::FINALIZE`); `EVENT_TYPE::CLOSE` is kept as a deprecated alias with the new value.
- `close` after a failed final publish returns the failure and leaves the context open with its events queued, so it can be retried. It used to seal the context and orphan the events.
- A failed `refresh` no longer marks the context failed: it logs the error, returns it and keeps the current data. `refresh` on a failed context is a no-op.
- `ABSmartly.new` is a class-level overload (config object or `endpoint, api_key:, application:, environment:, ...`); subclasses that override `initialize` must adapt.
- `Client#headers` is no longer a public accessor.
- `Array.wrap`, `String#underscore` and `String#compare_to` core extensions removed.
- `match` no longer caps pattern/text length (the JavaScript SDK has no cap); an invalid pattern still evaluates to null.

### Fixed
- Units are hashed from the UTF-8 encoding of their Unicode text: non-UTF-8 strings (e.g. UTF-16) are transcoded, surrogate pairs written as CESU-8 are joined into 4-byte sequences, and lone surrogates or invalid bytes become U+FFFD.
- HTTP: network errors and 5xx responses are retried for both the context fetch and publish; a connection error fails the context instead of raising out of `create_context`.
- A raising event logger no longer breaks the operation that logs (initialization, treatment, publish).
- `in` with an object haystack finds symbol keys (audience attributes are parsed with symbolized names).
- An indeterminate audience result (no filter) keeps a cached assignment instead of re-assigning.
- An exposure or goal can no longer be queued after `close` sealed the context.

### Added
- Configurable connection pool settings: `pool_size` and `pool_idle_timeout`
- Backwards-compatible class aliases: `SDK` (alias for `ABSmartly`) and `SDKConfig` (alias for `ABSmartlyConfig`)

## [0.1.0] - 2022-08-03

Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
absmartly-sdk (1.3.0)
absmartly-sdk (2.0.0)
base64 (~> 0.2)
faraday (~> 2.0)
faraday-net_http_persistent (~> 2.0)
Expand Down
560 changes: 415 additions & 145 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion absmartly.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
spec.files = Dir.chdir(__dir__) do
`git ls-files -z`.split("\x0").reject do |f|
(f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
(f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features|example)/|\.(?:git|travis|circleci)|appveyor)})
end
end
spec.bindir = "exe"
Expand Down
8 changes: 4 additions & 4 deletions example/example.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

# config file
Absmartly.configure_client do |config|
config.endpoint = "https://demo.absmartly.io/v1"
config.api_key = "x3ZyxmeKmb6n3VilTGs5I6-tBdaS9gYyr3i4YQXmUZcpPhH8nd8ev44NoEL_3yvA"
config.application = "www"
config.environment = "prod"
config.endpoint = ENV.fetch("ABSMARTLY_ENDPOINT", "https://demo.absmartly.io/v1")
config.api_key = ENV.fetch("ABSMARTLY_API_KEY") { raise "Set ABSMARTLY_API_KEY environment variable" }
config.application = ENV.fetch("ABSMARTLY_APPLICATION", "www")
config.environment = ENV.fetch("ABSMARTLY_ENVIRONMENT", "prod")
end

# define a new context request
Expand Down
85 changes: 83 additions & 2 deletions lib/a_b_smartly.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
# frozen_string_literal: true

require "time"
require "ostruct"
require_relative "context"
require_relative "audience_matcher"
require_relative "default_context_data_provider"
require_relative "default_context_publisher"
require_relative "default_context_event_handler"
require_relative "default_variable_parser"
require_relative "default_audience_deserializer"
require_relative "scheduled_thread_pool_executor"
require_relative "client_config"
require_relative "client"

class ABSmartly
attr_accessor :context_data_provider, :context_event_handler,
Expand All @@ -18,7 +22,33 @@ def self.create(config)
ABSmartly.new(config)
end

def initialize(config)
def self.new(config_or_endpoint = nil,
api_key: nil,
application: nil,
environment: nil,
timeout: nil,
retries: nil,
context_event_logger: nil)
if config_or_endpoint.is_a?(ABSmartlyConfig)
allocate.tap { |instance| instance.send(:initialize_from_config, config_or_endpoint) }
else
allocate.tap { |instance|
instance.send(:initialize_from_params,
config_or_endpoint,
api_key,
application,
environment,
timeout,
retries,
context_event_logger
)
}
end
end

private

def initialize_from_config(config)
@context_data_provider = config.context_data_provider
@context_event_handler = config.context_event_handler
@context_event_logger = config.context_event_logger
Expand All @@ -35,7 +65,7 @@ def initialize(config)
end

if @context_event_handler.nil?
@context_event_handler = DefaultContextEventHandler.new(@client)
@context_event_handler = DefaultContextPublisher.new(@client)
end
end

Expand All @@ -51,13 +81,62 @@ def initialize(config)
end
end

def initialize_from_params(endpoint, api_key, application, environment, timeout, retries, event_logger)
raise ArgumentError.new("Missing required parameter: endpoint") if endpoint.nil? || endpoint.to_s.strip.empty?
raise ArgumentError.new("Missing required parameter: api_key") if api_key.nil? || api_key.to_s.strip.empty?
raise ArgumentError.new("Missing required parameter: application") if application.nil? || application.to_s.strip.empty?
raise ArgumentError.new("Missing required parameter: environment") if environment.nil? || environment.to_s.strip.empty?

timeout ||= 3000
retries ||= 5

raise ArgumentError.new("timeout must be a positive number") if Integer(timeout) <= 0
raise ArgumentError.new("retries must be a non-negative number") if Integer(retries) < 0

Comment thread
coderabbitai[bot] marked this conversation as resolved.
client_config = ClientConfig.create
client_config.endpoint = endpoint
client_config.api_key = api_key
client_config.application = application
client_config.environment = environment
client_config.connect_timeout = timeout.to_f / 1000.0
client_config.connection_request_timeout = timeout.to_f / 1000.0
client_config.max_retries = retries

@client = Client.create(client_config)
@context_data_provider = DefaultContextDataProvider.new(@client)
@context_event_handler = DefaultContextPublisher.new(@client)
@context_event_logger = event_logger
@variable_parser = DefaultVariableParser.new
@audience_deserializer = DefaultAudienceDeserializer.new
@scheduler = ScheduledThreadPoolExecutor.new(1)
end

public

def create_context(config)
validate_params(config)
Context.create(get_utc_format, config, @context_data_provider.context_data,
@context_data_provider, @context_event_handler, @context_event_logger, @variable_parser,
AudienceMatcher.new(@audience_deserializer))
end

def create_context_async(config)
validate_params(config)
context = Context.create_async(get_utc_format, config, @context_data_provider,
@context_event_handler, @context_event_logger, @variable_parser,
AudienceMatcher.new(@audience_deserializer))
data_provider = @context_data_provider
Thread.new do
begin
data_future = data_provider.context_data
context.set_data(data_future)
rescue => e
context.set_data(OpenStruct.new(success?: false, exception: e))
end
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.
context
end

def create_context_with(config, data)
validate_params(config)
Context.create(get_utc_format, config, data,
Expand Down Expand Up @@ -86,3 +165,5 @@ def validate_params(params)
end
end
end

SDK = ABSmartly
2 changes: 2 additions & 0 deletions lib/a_b_smartly_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ def client=(client)
self
end
end

SDKConfig = ABSmartlyConfig
4 changes: 4 additions & 0 deletions lib/absmartly.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ def create_context(context_config)
sdk.create_context(context_config)
end

def create_context_async(context_config)
sdk.create_context_async(context_config)
end

def create_context_with(context_config, data)
sdk.create_context_with(context_config, data)
end
Expand Down
2 changes: 1 addition & 1 deletion lib/absmartly/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Absmartly
VERSION = "1.3.0"
VERSION = "2.0.0"
end
6 changes: 5 additions & 1 deletion lib/audience_matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ def evaluate(audience, attributes)
Result.new(@json_expr.evaluate_boolean_expr(filter, attributes))
end
end
rescue
rescue JSON::ParserError => e
warn("Failed to parse audience JSON: #{e.message}")
nil
rescue StandardError => e
warn("Audience evaluation failed: #{e.class} - #{e.message}")
nil
end
end
28 changes: 25 additions & 3 deletions lib/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
require_relative "default_context_event_serializer"

class Client
attr_accessor :url, :query, :headers, :http_client, :executor, :deserializer, :serializer
attr_accessor :url, :query, :http_client, :executor, :deserializer, :serializer
attr_reader :data_future, :promise, :exception

def self.create(config, http_client = nil)
Expand Down Expand Up @@ -50,9 +50,21 @@ def initialize(config, http_client = nil)
end

def context_data
@promise = @http_client.get(@url, @query, @headers)
@exception = nil
@data_future = nil
begin
@promise = @http_client.get(@url, @query, @headers)
rescue StandardError => e
# A network failure fails the context instead of escaping create_context.
@promise = nil
@exception = e
warn("Failed to fetch context data: #{e.class} - #{e.message}")
return self
end

unless @promise.success?
@exception = Exception.new(@promise.body)
warn("Failed to fetch context data (status=#{@promise.respond_to?(:status) ? @promise.status : 'unknown'})")
return self
Comment thread
coderabbitai[bot] marked this conversation as resolved.
end

Expand All @@ -64,7 +76,12 @@ def context_data
def publish(event)
content = @serializer.serialize(event)
response = @http_client.put(@url, nil, @headers, content)
return Exception.new(response.body) unless response.success?

unless response.success?
error = Exception.new(response.body)
warn("Publish failed (status=#{response.respond_to?(:status) ? response.status : 'unknown'})")
return error
end

response
end
Expand All @@ -76,4 +93,9 @@ def close
def success?
@promise&.success? || false
end

def inspect
"#<Client url=#{@url.inspect}>"
end

end
Loading
Loading