From 14ccbd5e86cf9d80da7babe02fbb050f788d4d9c Mon Sep 17 00:00:00 2001 From: Cal Courtney Date: Fri, 16 Jan 2026 16:33:54 +0000 Subject: [PATCH 1/4] feat: add global cutom event logger --- README.md | 33 ++++++++++++++++++++++++++++++--- lib/absmartly.rb | 4 +++- spec/absmartly_spec.rb | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index aaae440..fd4b768 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,37 @@ end The A/B Smartly SDK can be instantiated with an event logger used for all contexts. In addition, an event logger can be specified when creating a -particular context, in the `[CONTEXT_CONFIG_VARIABLE]`. +particular context in the context config. -``` -Custom Event Logger Code +```ruby +class MyEventLogger < ContextEventLogger + def handle_event(event, data) + case event + when EVENT_TYPE::EXPOSURE + puts "Exposure: #{data}" + when EVENT_TYPE::GOAL + puts "Goal: #{data}" + when EVENT_TYPE::ERROR + puts "Error: #{data}" + when EVENT_TYPE::PUBLISH + puts "Publish: #{data}" + when EVENT_TYPE::READY + puts "Ready: #{data}" + when EVENT_TYPE::REFRESH + puts "Refresh: #{data}" + when EVENT_TYPE::CLOSE + puts "Close" + end + end +end + +Absmartly.configure_client do |config| + config.endpoint = "https://your-company.absmartly.io/v1" + config.api_key = "YOUR-API-KEY" + config.application = "website" + config.environment = "development" + config.event_logger = MyEventLogger.new +end ``` The data parameter depends on the type of event. Currently, the SDK logs the diff --git a/lib/absmartly.rb b/lib/absmartly.rb index 3c26327..2b37374 100644 --- a/lib/absmartly.rb +++ b/lib/absmartly.rb @@ -13,7 +13,8 @@ class Error < StandardError class << self attr_accessor :endpoint, :api_key, :application, :environment, - :connect_timeout, :connection_request_timeout, :retry_interval, :max_retries + :connect_timeout, :connection_request_timeout, :retry_interval, :max_retries, + :event_logger def configure_client yield self @@ -56,6 +57,7 @@ def client_config def sdk_config @sdk_config = ABSmartlyConfig.create @sdk_config.client = Client.create(client_config) + @sdk_config.context_event_logger = @event_logger @sdk_config end diff --git a/spec/absmartly_spec.rb b/spec/absmartly_spec.rb index 6b9a55a..381c9aa 100644 --- a/spec/absmartly_spec.rb +++ b/spec/absmartly_spec.rb @@ -39,4 +39,40 @@ expect(Absmartly.max_retries).to eq(3) end end + + describe ".event_logger" do + after do + Absmartly.event_logger = nil + end + + it "has event_logger accessor" do + expect(Absmartly).to respond_to(:event_logger) + expect(Absmartly).to respond_to(:event_logger=) + end + + it "can be set via configure_client" do + logger = double("event_logger") + + Absmartly.configure_client do |config| + config.event_logger = logger + end + + expect(Absmartly.event_logger).to eq(logger) + end + + it "flows through to ABSmartlyConfig.context_event_logger" do + logger = double("event_logger") + + Absmartly.configure_client do |config| + config.endpoint = "https://test.absmartly.io/v1" + config.api_key = "test-api-key" + config.application = "test-app" + config.environment = "test" + config.event_logger = logger + end + + sdk_config = Absmartly.send(:sdk_config) + expect(sdk_config.context_event_logger).to eq(logger) + end + end end From 088c94dd31dbe1193ceaecd9f89a04221894cac1 Mon Sep 17 00:00:00 2001 From: Cal Courtney Date: Fri, 16 Jan 2026 16:48:54 +0000 Subject: [PATCH 2/4] test: add context logger integration test --- spec/absmartly_spec.rb | 166 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/spec/absmartly_spec.rb b/spec/absmartly_spec.rb index 381c9aa..38b1bac 100644 --- a/spec/absmartly_spec.rb +++ b/spec/absmartly_spec.rb @@ -1,5 +1,39 @@ # frozen_string_literal: true +require "context" +require "context_config" +require "default_context_data_deserializer" +require "default_variable_parser" +require "default_audience_deserializer" +require "context_data_provider" +require "default_context_data_provider" +require "context_event_handler" +require "context_event_logger" +require "audience_matcher" +require "json/unit" +require "logger" + +class MockContextEventLoggerProxy < ContextEventLogger + attr_accessor :called, :events, :logger + + def initialize + @called = 0 + @events = [] + @logger = Logger.new(STDOUT) + @logger.level = Logger::WARN + end + + def handle_event(event, data) + @called += 1 + @events << { event: event, data: data } + end + + def clear + @called = 0 + @events = [] + end +end + RSpec.describe Absmartly do it "has a version number" do expect(Absmartly::VERSION).not_to be nil @@ -75,4 +109,136 @@ expect(sdk_config.context_event_logger).to eq(logger) end end + + describe ".event_logger integration" do + let(:units) do + { + session_id: "e791e240fcd3df7d238cfc285f475e8152fcc0ec", + user_id: "123456789", + email: "bleh@absmartly.com" + } + end + let(:publish_units) do + [ + Unit.new("session_id", "pAE3a1i5Drs5mKRNq56adA"), + Unit.new("user_id", "JfnnlDI7RTiF9RgfG2JNCw"), + Unit.new("email", "IuqYkNRfEx5yClel4j3NbA") + ] + end + let(:clock) { Time.at(1620000000000 / 1000) } + let(:clock_in_millis) { clock.to_i } + + let(:descr) { DefaultContextDataDeserializer.new } + let(:json) { resource("context.json") } + let(:data) { descr.deserialize(json, 0, json.length) } + + let(:data_future) { OpenStruct.new(data_future: nil, success?: true) } + + let(:data_provider) { DefaultContextDataProvider.new(client_mock) } + let(:data_future_ready) { data_provider.context_data } + + let(:publish_future) { OpenStruct.new(success?: true) } + let(:event_handler) do + ev = instance_double(ContextEventHandler) + allow(ev).to receive(:publish).and_return(publish_future) + ev + end + + let(:mock_logger) do + logger = MockContextEventLoggerProxy.new + allow(logger).to receive(:handle_event).and_call_original + logger + end + + let(:variable_parser) { DefaultVariableParser.new } + let(:audience_matcher) { AudienceMatcher.new(DefaultAudienceDeserializer.new) } + + def client_mock + client = instance_double(Client) + allow(client).to receive(:context_data).and_return(OpenStruct.new(data_future: data, success?: true)) + client + end + + def create_ready_context + config = ContextConfig.create + config.set_units(units) + + Context.create(clock, config, data_future_ready, data_provider, + event_handler, mock_logger, variable_parser, audience_matcher) + end + + after do + Absmartly.event_logger = nil + end + + context "when configured globally" do + before do + Absmartly.configure_client do |config| + config.endpoint = "https://test.absmartly.io/v1" + config.api_key = "test-key" + config.application = "test-app" + config.environment = "test" + config.event_logger = mock_logger + end + end + + it "receives READY event on context creation" do + mock_logger.clear + context = create_ready_context + expect(mock_logger).to have_received(:handle_event) + .with(ContextEventLogger::EVENT_TYPE::READY, data).once + end + + it "receives EXPOSURE event with correct values when treatment() is called" do + mock_logger.clear + context = create_ready_context + mock_logger.clear + + context.treatment("exp_test_ab") + + expected_exposure = Exposure.new( + 1, "exp_test_ab", "session_id", 1, clock_in_millis, + true, true, false, false, false, false + ) + expect(mock_logger).to have_received(:handle_event) + .with(ContextEventLogger::EVENT_TYPE::EXPOSURE, expected_exposure).once + end + + it "receives GOAL event with correct values when track() is called" do + mock_logger.clear + context = create_ready_context + mock_logger.clear + + properties = { amount: 125, hours: 245 } + context.track("goal1", properties) + + expected_goal = GoalAchievement.new("goal1", clock_in_millis, properties) + expect(mock_logger).to have_received(:handle_event) + .with(ContextEventLogger::EVENT_TYPE::GOAL, expected_goal).once + end + + it "receives PUBLISH event when publish() is called" do + mock_logger.clear + context = create_ready_context + context.track("goal1", { amount: 125 }) + mock_logger.clear + + context.publish + + expect(mock_logger).to have_received(:handle_event) + .with(ContextEventLogger::EVENT_TYPE::PUBLISH, instance_of(PublishEvent)).once + end + + it "receives CLOSE event when close() is called" do + mock_logger.clear + context = create_ready_context + mock_logger.clear + + context.close + + expect(mock_logger).to have_received(:handle_event) + .with(ContextEventLogger::EVENT_TYPE::CLOSE, nil).once + end + end + end end From f805cb074b758495e9ad241be3205b4bc31003d8 Mon Sep 17 00:00:00 2001 From: Cal Courtney Date: Fri, 16 Jan 2026 17:00:40 +0000 Subject: [PATCH 3/4] fix: coderabbit suggestions --- README.md | 2 +- spec/absmartly_spec.rb | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fd4b768..9f002c5 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ end | connection_request_timeout | `number` | ❌ | `3.0` | The request timeout in seconds. | | retry_interval | `number` | ❌ | `0.5` | The initial retry interval in seconds (uses exponential backoff). | | max_retries | `number` | ❌ | `5` | The maximum number of retries before giving up. | -| eventLogger | `(context, eventName, data) => void` | ❌ | See "Using a Custom Event Logger" below | A callback function which runs after SDK events. | +| event_logger | `(context, eventName, data) => void` | ❌ | See "Using a Custom Event Logger" below | A callback function which runs after SDK events. | ### Using a Custom Event Logger diff --git a/spec/absmartly_spec.rb b/spec/absmartly_spec.rb index 38b1bac..949ac14 100644 --- a/spec/absmartly_spec.rb +++ b/spec/absmartly_spec.rb @@ -168,6 +168,10 @@ def create_ready_context end after do + Absmartly.endpoint = nil + Absmartly.api_key = nil + Absmartly.application = nil + Absmartly.environment = nil Absmartly.event_logger = nil end @@ -184,7 +188,7 @@ def create_ready_context it "receives READY event on context creation" do mock_logger.clear - context = create_ready_context + create_ready_context expect(mock_logger).to have_received(:handle_event) .with(ContextEventLogger::EVENT_TYPE::READY, data).once end From 10dd24cd6abf110d9a301e710e66e9256cfa64f0 Mon Sep 17 00:00:00 2001 From: Cal Courtney Date: Fri, 16 Jan 2026 18:09:22 +0000 Subject: [PATCH 4/4] fix: coderabbit comments --- README.md | 2 +- spec/absmartly_spec.rb | 31 ++++++++++++++++++++++--------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9f002c5..56745e2 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ end | connection_request_timeout | `number` | ❌ | `3.0` | The request timeout in seconds. | | retry_interval | `number` | ❌ | `0.5` | The initial retry interval in seconds (uses exponential backoff). | | max_retries | `number` | ❌ | `5` | The maximum number of retries before giving up. | -| event_logger | `(context, eventName, data) => void` | ❌ | See "Using a Custom Event Logger" below | A callback function which runs after SDK events. | +| event_logger | `ContextEventLogger` | ❌ | See "Using a Custom Event Logger" below | A `ContextEventLogger` instance implementing `handle_event(event, data)` to receive SDK events. | ### Using a Custom Event Logger diff --git a/spec/absmartly_spec.rb b/spec/absmartly_spec.rb index 949ac14..9f71df9 100644 --- a/spec/absmartly_spec.rb +++ b/spec/absmartly_spec.rb @@ -156,6 +156,7 @@ def clear def client_mock client = instance_double(Client) allow(client).to receive(:context_data).and_return(OpenStruct.new(data_future: data, success?: true)) + allow(client).to receive(:publish).and_return(OpenStruct.new(success?: true)) client end @@ -163,8 +164,7 @@ def create_ready_context config = ContextConfig.create config.set_units(units) - Context.create(clock, config, data_future_ready, data_provider, - event_handler, mock_logger, variable_parser, audience_matcher) + Absmartly.create_context(config) end after do @@ -173,10 +173,14 @@ def create_ready_context Absmartly.application = nil Absmartly.environment = nil Absmartly.event_logger = nil + Absmartly.instance_variable_set(:@sdk, nil) + Absmartly.instance_variable_set(:@sdk_config, nil) end context "when configured globally" do before do + allow(Client).to receive(:create).and_return(client_mock) + Absmartly.configure_client do |config| config.endpoint = "https://test.absmartly.io/v1" config.api_key = "test-key" @@ -200,12 +204,19 @@ def create_ready_context context.treatment("exp_test_ab") - expected_exposure = Exposure.new( - 1, "exp_test_ab", "session_id", 1, clock_in_millis, - true, true, false, false, false, false - ) expect(mock_logger).to have_received(:handle_event) - .with(ContextEventLogger::EVENT_TYPE::EXPOSURE, expected_exposure).once + .with(ContextEventLogger::EVENT_TYPE::EXPOSURE, satisfy { |exposure| + exposure.id == 1 && + exposure.name == "exp_test_ab" && + exposure.unit == "session_id" && + exposure.variant == 1 && + exposure.assigned == true && + exposure.eligible == true && + exposure.overridden == false && + exposure.full_on == false && + exposure.custom == false && + exposure.audience_mismatch == false + }).once end it "receives GOAL event with correct values when track() is called" do @@ -216,9 +227,11 @@ def create_ready_context properties = { amount: 125, hours: 245 } context.track("goal1", properties) - expected_goal = GoalAchievement.new("goal1", clock_in_millis, properties) expect(mock_logger).to have_received(:handle_event) - .with(ContextEventLogger::EVENT_TYPE::GOAL, expected_goal).once + .with(ContextEventLogger::EVENT_TYPE::GOAL, satisfy { |goal| + goal.name == "goal1" && + goal.properties == properties + }).once end it "receives PUBLISH event when publish() is called" do