diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index 270f0d3..032b14a 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,7 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.3.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.7.0 with: - spec-ref: v0.3.0 + spec-ref: v0.7.0 language: ruby diff --git a/lib/flagsmith.rb b/lib/flagsmith.rb index 51ae62a..2173ce8 100644 --- a/lib/flagsmith.rb +++ b/lib/flagsmith.rb @@ -14,6 +14,7 @@ require 'flagsmith/sdk/config' require 'flagsmith/sdk/errors' require 'flagsmith/sdk/intervals' +require 'flagsmith/sdk/event_processor' require 'flagsmith/sdk/utils' require 'flagsmith/sdk/pooling_manager' require 'flagsmith/sdk/models/flags' @@ -44,13 +45,18 @@ class Client # rubocop:disable Metrics/ClassLength # feature_value_for_identity = identity_flags.get_feature_value('foo') # # identity_segments = flagsmith.get_identity_segments('identifier', {'foo': 'bar'}) + # + # With enable_events: true, experiment flags record exposures, and events can be tracked: + # + # flag = flagsmith.get_experiment_flag('foo', 'identifier', plan: 'premium') + # flagsmith.track_event('purchase', identifier: 'identifier', value: 42) # Available Configs. # # :environment_key, :api_url, :custom_headers, :request_timeout_seconds, :enable_local_evaluation, # :environment_refresh_interval_seconds, :retries, :enable_analytics, :default_flag_handler, # :offline_mode, :offline_handler, :polling_manager_failure_limit - # :realtime_api_url, :enable_realtime_updates, :logger + # :realtime_api_url, :enable_realtime_updates, :logger, :enable_events, :events_api_url # # You can see full description in the Flagsmith::Config @@ -68,6 +74,7 @@ def initialize(config) api_client analytics_processor + event_processor environment_data_polling_manager load_offline_handler end @@ -109,6 +116,12 @@ def analytics_processor ) end + def event_processor + return nil unless @config.enable_events? + + @event_processor ||= Flagsmith::EventProcessor.new(@config).tap(&:start) + end + def load_offline_handler @environment = offline_handler.environment if offline_handler end @@ -205,6 +218,47 @@ def get_value_for_identity(feature_name, user_id = nil, default: nil) flag.value end + # Get a flag for an identity, taking the same traits as get_identity_flags, and record the + # identity's exposure to the variant it was served when it is in the feature's running experiment. + # Requires enable_events. Exposure needs remote evaluation: the environment document used by local + # evaluation and offline mode carries no experiment data, so no exposure is recorded in those modes. + def get_experiment_flag(feature_name, identifier, **traits) + require_events! + flag = get_identity_flags(identifier, **traits)[feature_name] + skip_reason = exposure_skip_reason(flag) + if skip_reason + @config.logger.debug "Not sending #{Flagsmith::EventProcessor::FLAG_EXPOSURE_EVENT} for feature #{feature_name}: #{skip_reason}." + else + track_exposure_event(feature_name, identifier: identifier, value: flag.variant, traits: traits, + metadata: { experiment_id: flag.experiment.id }) + end + flag + end + + # Send an application event, e.g. a conversion, to Flagsmith. Requires enable_events. + def track_event(event, identifier: nil, value: nil, traits: nil, metadata: nil) + require_events! + if event.start_with?('$') + raise Flagsmith::ClientError, + "Event name \"#{event}\" uses the \"$\" prefix, which is reserved for events sent by the SDK." + end + + event_processor.track_event(event, identifier: identifier, value: value, traits: traits, metadata: metadata) + end + + # Record that an identity was shown a variant of a feature. Requires enable_events. + def track_exposure_event(feature_name, identifier:, value: nil, traits: nil, metadata: nil) + require_events! + if identifier.to_s.empty? + @config.logger.warn "Not sending #{Flagsmith::EventProcessor::FLAG_EXPOSURE_EVENT} for feature #{feature_name}: " \ + 'an exposure requires an identifier to reconcile with conversion events.' + return + end + + event_processor.track_exposure_event(feature_name, identifier: identifier, value: value, traits: traits, + metadata: metadata) + end + def get_identity_segments(identifier, traits = {}) raise Flagsmith::ClientError, 'Local evaluation or offline handler is required to obtain identity segments.' unless environment @@ -221,6 +275,19 @@ def get_identity_segments(identifier, traits = {}) private + def require_events! + raise Flagsmith::ClientError, 'The enable_events config param is required to send events.' unless event_processor + end + + def exposure_skip_reason(flag) + return 'feature not found' unless flag.is_a?(Flagsmith::Flags::Flag) + return 'feature is disabled' unless flag.enabled? + return 'experiments need remote evaluation, not local evaluation or offline mode' if environment + return 'identity is not in a running experiment' unless flag.experiment&.in_experiment? + + 'flag has no variant' if flag.variant.nil? + end + def environment_flags_from_document # rubocop:disable Metrics/MethodLength context = Flagsmith::Engine::Mappers.get_evaluation_context(environment) diff --git a/lib/flagsmith/sdk/config.rb b/lib/flagsmith/sdk/config.rb index 172201c..2a70e48 100644 --- a/lib/flagsmith/sdk/config.rb +++ b/lib/flagsmith/sdk/config.rb @@ -5,12 +5,13 @@ module Flagsmith class Config DEFAULT_API_URL = 'https://edge.api.flagsmith.com/api/v1/' DEFAULT_REALTIME_API_URL = 'https://realtime.flagsmith.com/' + DEFAULT_EVENTS_API_URL = 'https://events.api.flagsmith.com/' OPTIONS = %i[ environment_key api_url custom_headers request_timeout_seconds enable_local_evaluation environment_refresh_interval_seconds retries enable_analytics default_flag_handler offline_mode offline_handler polling_manager_failure_limit - realtime_api_url enable_realtime_updates logger + realtime_api_url enable_realtime_updates logger enable_events events_api_url ].freeze # Available Configs @@ -47,6 +48,9 @@ class Config # non-standard realtime endpoint. # +enable_realtime_updates+ - A boolean to enable realtime updates. # +logger+ - Pass your logger, default is Logger.new($stdout) + # +enable_events+ - if enabled, sends tracking and exposure events to the + # Flagsmith events API to power experiments + # +events_api_url+ - Override the URL of the Flagsmith events API # attr_reader(*OPTIONS) @@ -72,6 +76,10 @@ def realtime_mode? @enable_realtime_updates end + def enable_events? + @enable_events + end + def environment_flags_url 'flags/' end @@ -106,9 +114,15 @@ def build_config(options) @realtime_api_url << '/' unless @realtime_api_url.end_with? '/' @enable_realtime_updates = opts.fetch(:enable_realtime_updates, false) @logger = options.fetch(:logger, Logger.new($stdout).tap { |l| l.level = :debug }) + @enable_events = opts.fetch(:enable_events, false) + @events_api_url = with_trailing_slash(opts.fetch(:events_api_url, Flagsmith::Config::DEFAULT_EVENTS_API_URL)) end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + def with_trailing_slash(url) + url.end_with?('/') ? url : "#{url}/" + end + class << self def environment_key ENV.fetch('FLAGSMITH_ENVIRONMENT_KEY', nil) diff --git a/lib/flagsmith/sdk/event_processor.rb b/lib/flagsmith/sdk/event_processor.rb new file mode 100644 index 0000000..49003c0 --- /dev/null +++ b/lib/flagsmith/sdk/event_processor.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'set' + +module Flagsmith + # Started event processors, stopped together at exit. A stopped processor leaves, so it is not kept in memory. + module RunningEventProcessors + @processors = Set.new + @mutex = Mutex.new + at_exit { to_a.each(&:stop) } + + def self.add(processor) + @mutex.synchronize { @processors.add(processor) } + end + + def self.delete(processor) + @mutex.synchronize { @processors.delete(processor) } + end + + def self.to_a + @mutex.synchronize { @processors.to_a } + end + end + + # Buffers tracking and exposure events, and sends them to the Flagsmith events API in batches. + # + # Events are sent every +flush_interval_seconds+, as soon as +max_buffer_items+ are buffered, + # or on #flush. Sending events never raises into the application: failures are logged. + class EventProcessor + include Flagsmith::SDK::Intervals + + FLAG_EXPOSURE_EVENT = '$flag_exposure' + ENDPOINT = 'v1/events' + FLUSH_INTERVAL_SECONDS = 10 + MAX_BUFFER_ITEMS = 1000 + REQUEST_TIMEOUT_SECONDS = 3 + RETRIABLE_STATUSES = [500, 502, 503, 504].freeze + + def initialize(config, flush_interval_seconds: FLUSH_INTERVAL_SECONDS, max_buffer_items: MAX_BUFFER_ITEMS) + @logger = config.logger + @flush_interval_seconds = flush_interval_seconds + @max_buffer_items = max_buffer_items + @connection = build_connection(config) + @events = [] + @buffered_exposures = Set.new + @mutex = Mutex.new + @flush_mutex = Mutex.new + @flush_pending = false + end + + def track_event(event, identifier: nil, value: nil, traits: nil, metadata: nil) + buffer(payload(event, nil, identifier: identifier, value: value, traits: traits, metadata: metadata)) + end + + # Equal exposures within one flush interval add no information, so only the first is sent. + def track_exposure_event(feature_name, identifier:, value: nil, traits: nil, metadata: nil) + buffer(payload(FLAG_EXPOSURE_EVENT, feature_name, + identifier: identifier, value: value, traits: traits, metadata: metadata)) + end + + # +scheduled+ is internal: only the flush scheduled by a full buffer clears +@flush_pending+, once it holds + # the buffer, so at most one such flush is waiting at a time. + def flush(scheduled: false) + @flush_mutex.synchronize do + batch = @mutex.synchronize do + buffered = @events + @events = [] + @buffered_exposures.clear + @flush_pending = false if scheduled + buffered + end + send_events(batch) unless batch.empty? + end + end + + def start + return if @interval + + @interval = set_interval(@flush_interval_seconds) { flush } + RunningEventProcessors.add(self) + end + + def stop + @flush_mutex.synchronize { clear_interval(@interval) } if @interval # never interrupts a batch being sent + @interval = nil + RunningEventProcessors.delete(self) + flush + end + + private + + def build_connection(config) + Faraday.new(url: config.events_api_url, headers: headers(config)) do |f| + f.options.timeout = REQUEST_TIMEOUT_SECONDS + f.response :raise_error + f.request :retry, max: 1, interval: 1, methods: %i[post], retry_statuses: RETRIABLE_STATUSES, + exceptions: [*Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS, Faraday::ConnectionFailed] + end + end + + def headers(config) + config.custom_headers.merge( + 'Content-Type' => 'application/json; charset=utf-8', + 'X-Environment-Key' => config.environment_key, + 'User-Agent' => Flagsmith::SDK::Utils.user_agent, + 'Flagsmith-SDK-User-Agent' => Flagsmith::SDK::Utils.user_agent + ) + end + + def payload(event, feature_name, details) + { + event: event, + feature_name: feature_name, + identifier: details[:identifier], + value: details[:value]&.to_s, + traits: trait_values(details[:traits]), + metadata: (details[:metadata] || {}).transform_keys(&:to_s).merge('sdk_version' => Flagsmith::VERSION), + timestamp: (Time.now.to_f * 1000).to_i + } + end + + # Traits given as { value:, transient: } configurations are sent as their value. + def trait_values(traits) + return nil if traits.nil? || traits.empty? + + traits.to_h { |key, value| [key.to_s, value.is_a?(Hash) ? value[:value] : value] } + end + + def buffer(event) + schedule_flush = @mutex.synchronize do + next false if duplicate_exposure?(event) + + @events << event + next false if @flush_pending || @events.length < @max_buffer_items + + @flush_pending = true + end + Thread.new { flush(scheduled: true) } if schedule_flush + end + + def duplicate_exposure?(event) + return false unless event[:event] == FLAG_EXPOSURE_EVENT + return false if @buffered_exposures.add?(event.values_at(:feature_name, :identifier, :value)) + + @logger.debug "Skipping duplicate #{FLAG_EXPOSURE_EVENT} event for feature #{event[:feature_name]}" + true + end + + def send_events(events) + @connection.post(ENDPOINT, { events: events }.to_json) + rescue StandardError => e + @logger.warn "Unable to send #{events.length} events to the Flagsmith events API, dropping them: #{e}" + end + end +end diff --git a/lib/flagsmith/sdk/models/flags.rb b/lib/flagsmith/sdk/models/flags.rb index 59cea07..722db4a 100644 --- a/lib/flagsmith/sdk/models/flags.rb +++ b/lib/flagsmith/sdk/models/flags.rb @@ -30,15 +30,29 @@ def initialize(enabled:, value:) end end + # The running experiment an identity's flag was evaluated in, as returned by the API + Experiment = Struct.new(:id, :name, :in_experiment, keyword_init: true) do + alias_method :in_experiment?, :in_experiment + + def self.from_api(metadata) + experiment = metadata&.dig(:experiment) + return nil unless experiment + + new(id: experiment[:id], name: experiment[:name], in_experiment: experiment[:in_experiment]) + end + end + # 'live' Flag class as returned by API or local evaluation class Flag < BaseFlag - attr_reader :feature_name, :feature_id, :reason + attr_reader :feature_name, :feature_id, :reason, :variant, :experiment - def initialize(feature_name:, enabled:, value:, feature_id:, reason: nil) + def initialize(feature_name:, enabled:, value:, feature_id:, reason: nil, variant: nil, experiment: nil) # rubocop:disable Metrics/ParameterLists super(enabled: enabled, value: value, default: false) @feature_name = feature_name @feature_id = feature_id @reason = reason + @variant = variant + @experiment = experiment end def <=>(other) @@ -56,7 +70,9 @@ def to_h value: value, enabled: enabled, default: default, - reason: reason + reason: reason, + variant: variant, + experiment: experiment&.to_h } end @@ -75,7 +91,9 @@ def from_api(json_flag_data) enabled: json_flag_data[:enabled], value: json_flag_data.fetch(:feature_state_value) { json_flag_data[:value] }, feature_name: json_flag_data.dig(:feature, :name), - feature_id: json_flag_data.dig(:feature, :id) + feature_id: json_flag_data.dig(:feature, :id), + variant: json_flag_data[:variant], + experiment: Experiment.from_api(json_flag_data[:metadata]) ) end end diff --git a/sdk-spec/steps.rb b/sdk-spec/steps.rb index aed63d9..9b5b721 100644 --- a/sdk-spec/steps.rb +++ b/sdk-spec/steps.rb @@ -6,10 +6,12 @@ require 'flagsmith' +def as_spec_flag(flag) + { feature: flag.feature_name, enabled: flag.enabled, value: flag.value } +end + def as_spec_flags(flags) - flags.all_flags.map do |flag| - { feature: flag.feature_name, enabled: flag.enabled, value: flag.value } - end + flags.all_flags.map { |flag| as_spec_flag(flag) } end # Create the SDK client @@ -17,6 +19,13 @@ def create_client(environment_key, api_url) Flagsmith::Client.new(environment_key: environment_key, api_url: api_url) end +# Create the SDK client with events enabled +def create_client_with_events(environment_key, api_url, events_api_url) + Flagsmith::Client.new( + environment_key: environment_key, api_url: api_url, enable_events: true, events_api_url: events_api_url + ) +end + # When the application gets the environment flags def get_environment_flags(flagsmith) as_spec_flags(flagsmith.get_environment_flags) @@ -26,3 +35,18 @@ def get_environment_flags(flagsmith) def get_identity_flags(flagsmith, identifier, traits) as_spec_flags(flagsmith.get_identity_flags(identifier, **traits)) end + +# When the application gets the experiment flag "{feature}" for identity "{identifier}" with traits: +def get_experiment_flag(flagsmith, feature, identifier, traits) + [as_spec_flag(flagsmith.get_experiment_flag(feature, identifier, **traits))] +end + +# When the application tracks event "{event}" for identity "{identifier}" with value {value} and metadata: +def track_event(flagsmith, event, identifier, value, metadata) + flagsmith.track_event(event, identifier: identifier, value: value, metadata: metadata) +end + +# When the application tracks an exposure of feature "{feature}" for identity "{identifier}" with value {value} and metadata: +def track_exposure_event(flagsmith, feature, identifier, value, metadata) + flagsmith.track_exposure_event(feature, identifier: identifier, value: value, metadata: metadata) +end diff --git a/spec/sdk/event_processor_spec.rb b/spec/sdk/event_processor_spec.rb new file mode 100644 index 0000000..55cc71d --- /dev/null +++ b/spec/sdk/event_processor_spec.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Flagsmith::EventProcessor do + let(:logger) { instance_spy(Logger) } + let(:config) do + Flagsmith::Config.new( + environment_key: 'ser.environment-key', events_api_url: 'http://events.test', logger: logger, + custom_headers: { 'X-Proxy-Token' => 'token' } + ) + end + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:sent_batches) { [] } + let(:max_buffer_items) { 1000 } + + subject { described_class.new(config, max_buffer_items: max_buffer_items) } + + before do + allow(Faraday).to receive(:new).and_wrap_original do |original, *args, **options, &block| + original.call(*args, **options) do |faraday| + block.call(faraday) + faraday.adapter :test, stubs + end + end + end + + def respond_to_events_with(status) + stubs.post('/v1/events') do |env| + sent_batches << { headers: env.request_headers, body: JSON.parse(env.body) } + [status, {}, ''] + end + end + + it 'sends a tracked event to the events API with its details' do + # Given + respond_to_events_with(202) + + # When + subject.track_event('checkout_completed', identifier: 'user_123', value: 42, + traits: { plan: 'premium', age: { value: 30, transient: true } }, + metadata: { currency: 'GBP' }) + subject.flush + + # Then + expect(sent_batches.length).to eq(1) + expect(sent_batches[0][:body]).to match( + 'events' => [ + { + 'event' => 'checkout_completed', + 'feature_name' => nil, + 'identifier' => 'user_123', + 'value' => '42', + 'traits' => { 'plan' => 'premium', 'age' => 30 }, + 'metadata' => { 'currency' => 'GBP', 'sdk_version' => Flagsmith::VERSION }, + 'timestamp' => be_within(5_000).of((Time.now.to_f * 1000).to_i) + } + ] + ) + expect(sent_batches[0][:headers]).to include( + 'Content-Type' => 'application/json; charset=utf-8', + 'X-Environment-Key' => 'ser.environment-key', + 'Flagsmith-SDK-User-Agent' => "flagsmith-ruby-sdk/#{Flagsmith::VERSION}", + 'X-Proxy-Token' => 'token' + ) + end + + it 'sends an exposure as a $flag_exposure event for the feature' do + # Given + respond_to_events_with(202) + + # When + subject.track_exposure_event('banner', identifier: 'user_123', value: 'treatment', + metadata: { 'experiment_id' => 1 }) + subject.flush + + # Then + expect(sent_batches[0][:body]['events']).to match( + [ + hash_including( + 'event' => '$flag_exposure', 'feature_name' => 'banner', 'identifier' => 'user_123', + 'value' => 'treatment', 'traits' => nil, + 'metadata' => { 'experiment_id' => 1, 'sdk_version' => Flagsmith::VERSION } + ) + ] + ) + end + + it 'sends an exposure only once per flush interval' do + # Given + respond_to_events_with(202) + subject.track_exposure_event('banner', identifier: 'user_123', value: 'treatment') + + # When + subject.track_exposure_event('banner', identifier: 'user_123', value: 'treatment') + subject.track_exposure_event('banner', identifier: 'user_456', value: 'treatment') + subject.flush + + # Then + expect(sent_batches[0][:body]['events'].map { |event| event['identifier'] }).to eq(%w[user_123 user_456]) + expect(logger).to have_received(:debug) + .with('Skipping duplicate $flag_exposure event for feature banner').once + end + + it 'sends an exposure again in a later flush interval' do + # Given + respond_to_events_with(202) + subject.track_exposure_event('banner', identifier: 'user_123', value: 'treatment') + subject.flush + + # When + subject.track_exposure_event('banner', identifier: 'user_123', value: 'treatment') + subject.flush + + # Then + expect(sent_batches.length).to eq(2) + end + + it 'sends every tracked event, even when equal' do + # Given + respond_to_events_with(202) + + # When + 2.times { subject.track_event('checkout_completed', identifier: 'user_123') } + subject.flush + + # Then + expect(sent_batches[0][:body]['events'].length).to eq(2) + end + + it 'sends nothing when no events were tracked' do + # Given / When + subject.flush + + # Then + stubs.verify_stubbed_calls + expect(sent_batches).to be_empty + end + + context 'when the buffer is full' do + let(:max_buffer_items) { 2 } + + it 'sends the events without waiting for the flush interval' do + # Given + respond_to_events_with(202) + subject.track_event('first') + + # When + subject.track_event('second') + + # Then + Timeout.timeout(5) { sleep 0.01 while sent_batches.empty? } + expect(sent_batches[0][:body]['events'].map { |event| event['event'] }).to eq(%w[first second]) + end + + it 'schedules one flush at a time while a batch is being sent' do + # Given + allow(Thread).to receive(:new).and_return(nil) + subject.track_event('first') + + # When + subject.track_event('second') + subject.track_event('third') + subject.track_event('fourth') + + # Then + expect(Thread).to have_received(:new).once + end + end + + it 'drops events the events API rejects, without raising' do + # Given + respond_to_events_with(400) + subject.track_event('checkout_completed') + + # When + expect { subject.flush }.not_to raise_error + + # Then + expect(sent_batches.length).to eq(1) + expect(logger).to have_received(:warn).with(/\AUnable to send 1 events to the Flagsmith events API, dropping them: /) + end + + it 'sends events again once when the events API fails' do + # Given + respond_to_events_with(503) + respond_to_events_with(202) + subject.track_event('checkout_completed') + + # When + subject.flush + + # Then + expect(sent_batches.length).to eq(2) + expect(sent_batches[1][:body]).to eq(sent_batches[0][:body]) + expect(logger).not_to have_received(:warn) + end + + it 'sends the remaining events when stopped' do + # Given + respond_to_events_with(202) + subject.start + subject.track_event('checkout_completed') + + # When + subject.stop + + # Then + expect(sent_batches.length).to eq(1) + end + + it 'is stopped at exit while it is running' do + # Given / When + subject.start + + # Then + expect(Flagsmith::RunningEventProcessors.to_a).to include(subject) + subject.stop + end + + it 'is not kept for exit once stopped' do + # Given + subject.start + + # When + subject.stop + + # Then + expect(Flagsmith::RunningEventProcessors.to_a).not_to include(subject) + end +end diff --git a/spec/sdk/experiments_spec.rb b/spec/sdk/experiments_spec.rb new file mode 100644 index 0000000..0a8d566 --- /dev/null +++ b/spec/sdk/experiments_spec.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Flagsmith::Client do + let(:logger) { instance_spy(Logger) } + let(:api_client) { instance_double(Flagsmith::ApiClient) } + let(:enable_events) { true } + let(:experiment_metadata) { { experiment: { id: 42, name: 'Banner experiment', in_experiment: true } } } + let(:banner_flag) do + { + feature: { id: 1, name: 'banner' }, feature_state_value: 'hello treatment', enabled: true, + variant: 'treatment', metadata: experiment_metadata + } + end + + subject do + described_class.new(environment_key: 'ser.environment-key', enable_events: enable_events, logger: logger) + end + + before do + allow(Flagsmith::ApiClient).to receive(:new).and_return(api_client) + allow(api_client).to receive(:post).with('identities/', anything) + .and_return(OpenStruct.new(body: { flags: [banner_flag], traits: [] })) + allow(subject.event_processor).to receive(:track_exposure_event) if enable_events + end + + describe '#get_experiment_flag' do + it 'returns the identity flag with the experiment it was served in' do + # Given / When + flag = subject.get_experiment_flag('banner', 'user_123', plan: 'premium') + + # Then + expect(flag.value).to eq('hello treatment') + expect(flag.variant).to eq('treatment') + expect(flag.experiment).to eq( + Flagsmith::Flags::Experiment.new(id: 42, name: 'Banner experiment', in_experiment: true) + ) + expect(api_client).to have_received(:post).with( + 'identities/', { identifier: 'user_123', transient: false, traits: [{ trait_key: :plan, trait_value: 'premium' }] }.to_json + ) + end + + it 'records the exposure of an identity in a running experiment to its variant' do + # Given / When + subject.get_experiment_flag('banner', 'user_123', plan: 'premium') + + # Then + expect(subject.event_processor).to have_received(:track_exposure_event).with( + 'banner', identifier: 'user_123', value: 'treatment', traits: { plan: 'premium' }, metadata: { experiment_id: 42 } + ) + end + + context 'when the identity is not in the running experiment' do + let(:experiment_metadata) { { experiment: { id: 42, name: 'Banner experiment', in_experiment: false } } } + + it 'records no exposure' do + # Given / When + subject.get_experiment_flag('banner', 'user_123') + + # Then + expect(subject.event_processor).not_to have_received(:track_exposure_event) + expect(logger).to have_received(:debug) + .with('Not sending $flag_exposure for feature banner: identity is not in a running experiment.') + end + end + + context 'when the feature has no running experiment' do + let(:experiment_metadata) { nil } + + it 'returns the identity flag and records no exposure' do + # Given / When + flag = subject.get_experiment_flag('banner', 'user_123') + + # Then + expect(flag.experiment).to be_nil + expect(subject.event_processor).not_to have_received(:track_exposure_event) + expect(logger).to have_received(:debug) + .with('Not sending $flag_exposure for feature banner: identity is not in a running experiment.') + end + end + + context 'when the identity is in the running experiment without a variant' do + let(:banner_flag) do + { + feature: { id: 1, name: 'banner' }, feature_state_value: 'hello', enabled: true, metadata: experiment_metadata + } + end + + it 'records no exposure' do + # Given / When + subject.get_experiment_flag('banner', 'user_123') + + # Then + expect(subject.event_processor).not_to have_received(:track_exposure_event) + expect(logger).to have_received(:debug).with('Not sending $flag_exposure for feature banner: flag has no variant.') + end + end + + context 'when the feature is disabled' do + let(:banner_flag) do + { + feature: { id: 1, name: 'banner' }, feature_state_value: 'hello', enabled: false, + variant: 'treatment', metadata: experiment_metadata + } + end + + it 'records no exposure' do + # Given / When + subject.get_experiment_flag('banner', 'user_123') + + # Then + expect(subject.event_processor).not_to have_received(:track_exposure_event) + expect(logger).to have_received(:debug).with('Not sending $flag_exposure for feature banner: feature is disabled.') + end + end + + context 'when the feature is served by the default flag handler' do + subject do + described_class.new( + environment_key: 'ser.environment-key', enable_events: true, logger: logger, + default_flag_handler: ->(_) { Flagsmith::Flags::DefaultFlag.new(enabled: true, value: 'default') } + ) + end + + it 'returns the default flag and records no exposure' do + # Given / When + flag = subject.get_experiment_flag('unknown', 'user_123') + + # Then + expect(flag.value).to eq('default') + expect(subject.event_processor).not_to have_received(:track_exposure_event) + expect(logger).to have_received(:debug).with('Not sending $flag_exposure for feature unknown: feature not found.') + end + end + + context 'when the flags come from the environment document' do + subject do + described_class.new( + environment_key: 'ser.environment-key', enable_events: true, logger: logger, offline_mode: true, + offline_handler: Flagsmith::OfflineHandlers::LocalFileHandler.new('spec/sdk/fixtures/environment.json') + ) + end + + it 'returns the identity flag and records no exposure' do + # Given / When + flag = subject.get_experiment_flag('some_feature', 'user_123') + + # Then + expect(flag.value).to eq('some-value') + expect(subject.event_processor).not_to have_received(:track_exposure_event) + expect(logger).to have_received(:debug).with( + 'Not sending $flag_exposure for feature some_feature: ' \ + 'experiments need remote evaluation, not local evaluation or offline mode.' + ) + end + end + + context 'when events are not enabled' do + let(:enable_events) { false } + + it 'raises an error' do + # Given / When / Then + expect { subject.get_experiment_flag('banner', 'user_123') } + .to raise_error(Flagsmith::ClientError, 'The enable_events config param is required to send events.') + end + end + end + + describe '#track_event' do + it 'records the event' do + # Given + allow(subject.event_processor).to receive(:track_event) + + # When + subject.track_event('checkout_completed', identifier: 'user_123', value: 42, traits: { plan: 'premium' }, + metadata: { currency: 'GBP' }) + + # Then + expect(subject.event_processor).to have_received(:track_event).with( + 'checkout_completed', identifier: 'user_123', value: 42, traits: { plan: 'premium' }, metadata: { currency: 'GBP' } + ) + end + + it 'refuses event names reserved for the SDK' do + # Given / When / Then + expect { subject.track_event('$flag_exposure', identifier: 'user_123') } + .to raise_error(Flagsmith::ClientError, + 'Event name "$flag_exposure" uses the "$" prefix, which is reserved for events sent by the SDK.') + end + + context 'when events are not enabled' do + let(:enable_events) { false } + + it 'raises an error' do + # Given / When / Then + expect { subject.track_event('checkout_completed') } + .to raise_error(Flagsmith::ClientError, 'The enable_events config param is required to send events.') + end + end + end + + describe '#track_exposure_event' do + it 'records the exposure' do + # Given / When + subject.track_exposure_event('banner', identifier: 'user_123', value: 'control', metadata: { experiment_id: 1 }) + + # Then + expect(subject.event_processor).to have_received(:track_exposure_event).with( + 'banner', identifier: 'user_123', value: 'control', traits: nil, metadata: { experiment_id: 1 } + ) + end + + it 'records no exposure without an identifier' do + # Given / When + subject.track_exposure_event('banner', identifier: nil, value: 'control') + + # Then + expect(subject.event_processor).not_to have_received(:track_exposure_event) + expect(logger).to have_received(:warn).with( + 'Not sending $flag_exposure for feature banner: an exposure requires an identifier to reconcile with conversion events.' + ) + end + end + + describe '#event_processor' do + it 'sends events to the configured events API' do + # Given / When + client = described_class.new(environment_key: 'ser.environment-key', enable_events: true, + events_api_url: 'http://events.test/base', logger: logger) + + # Then + expect(client.config.events_api_url).to eq('http://events.test/base/') + expect(client.event_processor).to be_a(Flagsmith::EventProcessor) + end + + it 'sends events to Flagsmith by default' do + # Given / When / Then + expect(subject.config.events_api_url).to eq('https://events.api.flagsmith.com/') + end + end +end diff --git a/spec/sdk/realtime_client_spec.rb b/spec/sdk/realtime_client_spec.rb index 162deed..4badf29 100644 --- a/spec/sdk/realtime_client_spec.rb +++ b/spec/sdk/realtime_client_spec.rb @@ -61,6 +61,7 @@ # Mock the methods to avoid initialization interferring. allow_any_instance_of(Flagsmith::Client).to receive(:api_client) allow_any_instance_of(Flagsmith::Client).to receive(:analytics_processor) + allow_any_instance_of(Flagsmith::Client).to receive(:event_processor) allow_any_instance_of(Flagsmith::Client).to receive(:environment_data_polling_manager) allow_any_instance_of(Flagsmith::Client).to receive(:engine) allow_any_instance_of(Flagsmith::Client).to receive(:load_offline_handler)