From f0a536ce41e8ac85098ad689841d57b244529147 Mon Sep 17 00:00:00 2001 From: LaunchDarklyReleaseBot <86431345+LaunchDarklyReleaseBot@users.noreply.github.com> Date: Fri, 18 Mar 2022 14:13:09 -0700 Subject: [PATCH] prepare 6.3.2 release (#198) * fix doc comments * add YARD config so our docs show up correctly everywhere * don't need markup-provider option * rm obsolete proxy param * remove net-http-persistent * fix concurrent-ruby usage that breaks on Windows * add pipeline and clean up with with rm_rf instead of rm * fix highlight blocks * Hr/azure3 (#103) * Add Consul and Redis services to Windows. * Enable Consul and Redis testing * add dynamo (#104) * add experimentation event overrides for rules and fallthrough * warn & don't send event if identify or track has no valid user * include user in prereq flag events * rm unnecessary logic * more factory methods * update readme to refer to docs * add Ruby 2.6.2 to CI * fix missing require for net/http * stringify built-in user attributes in events, and secondary key for evals * make const names consistent * support metric value with track() * update method description * applying markdown templates and updating repository url references * Cleaning up markdown files * allow skipping database tests * Updating the package name (#115) * update package name * missed one * revert module entry point name change * bump ld-eventsource version for stream logging fix * use YAML.safe_load * add unit test and temporarily revert fix to demonstrate failure * restore fix * add comment about not using FileDataSource in production * drop events if inbox is full * update doc comment for track with metric_value * don't let user fall outside of last bucket in rollout * refactor evaluation logic and move it out of the main namespace * comments * fix type coercion behavior * make type coercion behavior consistent with earlier versions for now * whitespace * break up Evaluator tests further * make EvaluationReason an immutable class * FrozenError doesn't exist in older Ruby, use more general RuntimeError * precompute evaluation reasons when we receive a flag * rm unused * rename FeatureStore to DataStore * remove references to UpdateProcessor (now DataSource) * add event payload ID header * (6.0) drop support for old Ruby versions * add Ruby version constraint to gemspec * remove Rake dependency * update ld-eventsource to 1.0.2 which doesn't have Rake dependency * implement diagnostic events in Ruby (#130) * update ruby-eventsource to 1.0.3 for backoff bug * fix incorrect initialization of EventProcessor * remove install-time openssl check that breaks if you don't have rake * treat comparison with wrong data type as a non-match, not an exception (#134) * fail fast for nil SDK key when appropriate * tolerate nil value for user.custom (#137) * Only shutdown the Redis pool if it is owned by the SDK (#158) * Only shutdown a Redis pool created by SDK * Make pool shutdown behavior an option * improve doc comment * remove support for indirect/patch and indirect/put (#138) * update to json 2.3.1 (#139) * update json dep to 2.3.x to fix CVE * add publication of API docs on GitHub Pages (#143) * try fixing release metadata * update the default base url (#144) * revert renames of feature_store & update_processor * [ch92483] Use http gem and add socket factory support (#142) * update dependencies and add CI for ruby 3 (#141) * reference eventsource 2.0 in gemspec * add 5.x releasable branch for releaser * use Ruby 2.6.6 in releases * Removed the guides link * [ch99757] add alias method (#147) * don't send event for nil user evaluation * remove lockfile (#148) * rm redundant nil check * Experiment Allocation Changes (#150) * WIP - from sam's pairing session * starting sdk changes * adding tests and making sure everything works * adding more tests * removing the singleton for fallthrough * Revert "removing the singleton for fallthrough" This reverts commit dff7adbb809ecc63118d0fbff9742a88a039c679. * taking a different approach to keep things immutable * adding tests for untracked * remove unnecessary comment * making sure to return two values in all code paths Co-authored-by: pellyg-ld * Use camelCase for JSON property names (#151) The in_experiment attribute was added to reasons as part of #150 but it doesn't appear to be received in events. I think that's because it's sending it in JSON as "in_experiment" rather than "inExperiment" as we expect to parse it. * fixing ruby logic causing ih failures (#152) * fixing ruby logic * adding missing spec * Apply suggestions from code review Co-authored-by: Sam Stokes * pr tweaks * making spec language consistent Co-authored-by: Sam Stokes * add log warning for missing user key (#153) * add log warnings for nil/empty user key * rm warning for empty string key * fix test * diagnostic events should respect HTTPS_PROXY (#154) * minor test simplification (#155) * allow higher minor versions of json and http gems * allow v5.x of http gem (#157) * use Bundler 2.2.10 + modernize CI config (#158) * enable verbose rspec output * fix socket factory tests * restore log suppression * Replacing deprecated circleci image usage (#159) * use Releaser v2 config (#161) * Updates docs URLs * Update lib/ldclient-rb/ldclient.rb Co-authored-by: Louis Chan <91093020+louis-launchdarkly@users.noreply.github.com> * remove reliance on git in gemspec (#163) * use ruby-eventsource 2.1.1 for fix of sc-123850 and sc-125504 (#164) * use ruby-eventsource 2.1.1 for fix of sc-123850 and sc-125504 * comment phrasing * Start work on flag builder. * Add user targeting and rule builder * Add datasource implementation * Convert the current_flags hash to use symbols instead of strings as keys * Fix typo on FlagRuleBuilder copy constructor * minor refactoring of impl; Added use of new Clause struct instead of Hash in FlagRuleBuilder; Moved TestData.factory out of Impl namespace and renamed Impl to TestDataImpl * Add the doc comments * (big segments 1) add public config/interface/reason types (#167) * Cleanup docstrings to be YARD docs * Added Util.is_bool helper function to clean up the check for whether an object is a boolean; Removed the DeepCopyHash/DeepCopyArray objects in favor of deep_copy_hash and deep_copy_array functions * Move public classes out of Impl namespace. Most of it is in public namespace except for the data source now. * Move require of concurrent/atomics to the correct module * (big segments 2) implement Big Segments evaluation & status APIs (#168) * improve CONTRIBUTING.md with notes on code organization * add note about doc comments * Cleanup YARD warnings and cleanup docs * Address PR feedback: Move is_bool back to Impl namespace to avoid confusion; Remove unnecessary nil check on variations in build function; fixup comments * (big segments 3) implement Redis & DynamoDB big segment stores (#169) * add missing import * fix stale calculation * fix big segments user hash algorithm to use SHA256 * improve & refactor client/evaluation tests * more cleanup/DRY * add use_preconfigured_flag and use_preconfigured_segment to TestData (#173) * always cache big segment query result even if it's nil * comments * add test for cache expiration * use TestData in our own tests (#174) * use TestData in our own tests * fix test * replace LaunchDarkly::FileDataSource with LaunchDarkly::Integrations::FileData * update ruby-eventsource version for recent SSE fixes * Bump bundler version (#184) * Add ability to to set initial reconnect delay (#183) * Treat secondary as a built-in attribute (#180) * all_flags_state is invalid if store isn't initialized (#182) * identify should not emit events if user key is "" (#181) * Account for traffic allocation on all flags (#185) * Add contract tests (#178) * Fix string interpolation in log message (#187) * Default opts to empty hash when creating persistent feature store (#186) * Remove Hakiri badge from README (#188) Hakiri was sunset on January 31st, 2022 at which time our badge stopped working. * detect http/https proxy env vars when creating HTTP clients * rever accidental change Co-authored-by: LaunchDarklyCI Co-authored-by: Eli Bishop Co-authored-by: Harpo roeder Co-authored-by: hroederld <46500128+hroederld@users.noreply.github.com> Co-authored-by: Ben Woskow Co-authored-by: Ben Woskow <48036130+bwoskow-ld@users.noreply.github.com> Co-authored-by: Jacob Smith Co-authored-by: Elliot <35050275+Apache-HB@users.noreply.github.com> Co-authored-by: Kerrie Martinez Co-authored-by: pellyg-ld Co-authored-by: Sam Stokes Co-authored-by: LaunchDarklyReleaseBot Co-authored-by: Ember Stevens Co-authored-by: ember-stevens <79482775+ember-stevens@users.noreply.github.com> Co-authored-by: Louis Chan <91093020+louis-launchdarkly@users.noreply.github.com> Co-authored-by: Matthew M. Keeler Co-authored-by: Ben Levy Co-authored-by: Ben Levy Co-authored-by: Matthew M. Keeler --- .circleci/config.yml | 29 +++-- Makefile | 19 +++ README.md | 1 - contract-tests/Gemfile | 10 ++ contract-tests/README.md | 7 ++ contract-tests/client_entity.rb | 92 ++++++++++++++ contract-tests/service.rb | 112 ++++++++++++++++++ launchdarkly-server-sdk.gemspec | 2 +- lib/ldclient-rb/config.rb | 17 +++ lib/ldclient-rb/flags_state.rb | 35 ++++-- lib/ldclient-rb/impl/evaluator_operators.rb | 2 +- lib/ldclient-rb/impl/event_factory.rb | 21 ++-- .../impl/integrations/dynamodb_impl.rb | 2 +- lib/ldclient-rb/integrations/consul.rb | 2 +- lib/ldclient-rb/integrations/dynamodb.rb | 2 +- lib/ldclient-rb/integrations/redis.rb | 2 +- lib/ldclient-rb/ldclient.rb | 38 ++++-- lib/ldclient-rb/stream.rb | 3 +- lib/ldclient-rb/util.rb | 9 ++ spec/event_sender_spec.rb | 41 ++++--- spec/flags_state_spec.rb | 42 +++---- spec/impl/evaluator_operators_spec.rb | 4 +- spec/ldclient_evaluation_spec.rb | 16 +++ spec/ldclient_events_spec.rb | 4 +- spec/requestor_spec.rb | 27 +++-- 25 files changed, 437 insertions(+), 102 deletions(-) create mode 100644 Makefile create mode 100644 contract-tests/Gemfile create mode 100644 contract-tests/README.md create mode 100644 contract-tests/client_entity.rb create mode 100644 contract-tests/service.rb diff --git a/.circleci/config.yml b/.circleci/config.yml index 8ddba394..7ec25b1a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,8 +17,8 @@ workflows: name: Ruby 3.0 docker-image: cimg/ruby:3.0 - build-test-linux: - name: JRuby 9.2 - docker-image: jruby:9.2-jdk + name: JRuby 9.3 + docker-image: jruby:9.3-jdk jruby: true jobs: @@ -41,7 +41,7 @@ jobs: - when: condition: <> steps: - - run: gem install jruby-openssl # required by bundler, no effect on Ruby MRI + - run: gem install jruby-openssl -v 0.11.0 # required by bundler, no effect on Ruby MRI - run: apt-get update -y && apt-get install -y build-essential - when: condition: @@ -49,11 +49,22 @@ jobs: steps: - run: sudo apt-get update -y && sudo apt-get install -y build-essential - run: ruby -v - - run: gem install bundler -v 2.2.10 - - run: bundle _2.2.10_ install - - run: mkdir ./rspec - - run: bundle _2.2.10_ exec rspec --format documentation --format RspecJunitFormatter -o ./rspec/rspec.xml spec + - run: gem install bundler -v 2.2.33 + - run: bundle _2.2.33_ install + - run: mkdir /tmp/circle-artifacts + - run: bundle _2.2.33_ exec rspec --format documentation --format RspecJunitFormatter -o /tmp/circle-artifacts/rspec.xml spec + + - when: + condition: + not: <> + steps: + - run: make build-contract-tests + - run: + command: make start-contract-test-service + background: true + - run: TEST_HARNESS_PARAMS="-junit /tmp/circle-artifacts/contract-tests-junit.xml" make run-contract-tests + - store_test_results: - path: ./rspec + path: /tmp/circle-artifacts - store_artifacts: - path: ./rspec + path: /tmp/circle-artifacts diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..5b264f57 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +TEMP_TEST_OUTPUT=/tmp/contract-test-service.log + +build-contract-tests: + @cd contract-tests && bundle _2.2.33_ install + +start-contract-test-service: + @cd contract-tests && bundle _2.2.33_ exec ruby service.rb + +start-contract-test-service-bg: + @echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)" + @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & + +run-contract-tests: + @curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v1.0.0/downloader/run.sh \ + | VERSION=v1 PARAMS="-url http://localhost:9000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh + +contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests + +.PHONY: build-contract-tests start-contract-test-service run-contract-tests contract-tests diff --git a/README.md b/README.md index 8125c068..17e3bfc5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ LaunchDarkly Server-side SDK for Ruby [![Gem Version](https://badge.fury.io/rb/launchdarkly-server-sdk.svg)](http://badge.fury.io/rb/launchdarkly-server-sdk) [![Circle CI](https://circleci.com/gh/launchdarkly/ruby-server-sdk/tree/master.svg?style=svg)](https://circleci.com/gh/launchdarkly/ruby-server-sdk/tree/master) -[![Security](https://hakiri.io/github/launchdarkly/ruby-server-sdk/master.svg)](https://hakiri.io/github/launchdarkly/ruby-server-sdk/master) [![RubyDoc](https://img.shields.io/static/v1?label=docs+-+all+versions&message=reference&color=00add8)](https://www.rubydoc.info/gems/launchdarkly-server-sdk) [![GitHub Pages](https://img.shields.io/static/v1?label=docs+-+latest&message=reference&color=00add8)](https://launchdarkly.github.io/ruby-server-sdk) diff --git a/contract-tests/Gemfile b/contract-tests/Gemfile new file mode 100644 index 00000000..48b8812f --- /dev/null +++ b/contract-tests/Gemfile @@ -0,0 +1,10 @@ +source 'https://rubygems.org' + +gem 'launchdarkly-server-sdk', path: '..' + +gem 'sinatra', '~> 2.1' +# Sinatra can work with several server frameworks. In JRuby, we have to use glassfish (which +# is only available in JRuby). Otherwise we use thin (which is not available in JRuby). +gem 'glassfish', :platforms => :jruby +gem 'thin', :platforms => :ruby +gem 'json' diff --git a/contract-tests/README.md b/contract-tests/README.md new file mode 100644 index 00000000..aa3942b8 --- /dev/null +++ b/contract-tests/README.md @@ -0,0 +1,7 @@ +# SDK contract test service + +This directory contains an implementation of the cross-platform SDK testing protocol defined by https://github.com/launchdarkly/sdk-test-harness. See that project's `README` for details of this protocol, and the kinds of SDK capabilities that are relevant to the contract tests. This code should not need to be updated unless the SDK has added or removed such capabilities. + +To run these tests locally, run `make contract-tests` from the SDK project root directory. This downloads the correct version of the test harness tool automatically. + +Or, to test against an in-progress local version of the test harness, run `make start-contract-test-service` from the SDK project root directory; then, in the root directory of the `sdk-test-harness` project, build the test harness and run it from the command line. diff --git a/contract-tests/client_entity.rb b/contract-tests/client_entity.rb new file mode 100644 index 00000000..a9b7ccd5 --- /dev/null +++ b/contract-tests/client_entity.rb @@ -0,0 +1,92 @@ +require 'ld-eventsource' +require 'json' +require 'net/http' + +class ClientEntity + def initialize(log, config) + @log = log + + opts = {} + + opts[:logger] = log + + if config[:streaming] + streaming = config[:streaming] + opts[:stream_uri] = streaming[:baseUri] if !streaming[:baseUri].nil? + opts[:initial_reconnect_delay] = streaming[:initialRetryDelayMs] / 1_000.0 if !streaming[:initialRetryDelayMs].nil? + end + + if config[:events] + events = config[:events] + opts[:events_uri] = events[:baseUri] if events[:baseUri] + opts[:capacity] = events[:capacity] if events[:capacity] + opts[:diagnostic_opt_out] = !events[:enableDiagnostics] + opts[:all_attributes_private] = !!events[:allAttributesPrivate] + opts[:private_attribute_names] = events[:globalPrivateAttributes] + opts[:flush_interval] = (events[:flushIntervalMs] / 1_000) if events.has_key? :flushIntervalMs + opts[:inline_users_in_events] = events[:inlineUsers] || false + else + opts[:send_events] = false + end + + startWaitTimeMs = config[:startWaitTimeMs] || 5_000 + + @client = LaunchDarkly::LDClient.new( + config[:credential], + LaunchDarkly::Config.new(opts), + startWaitTimeMs / 1_000.0) + end + + def initialized? + @client.initialized? + end + + def evaluate(params) + response = {} + + if params[:detail] + detail = @client.variation_detail(params[:flagKey], params[:user], params[:defaultValue]) + response[:value] = detail.value + response[:variationIndex] = detail.variation_index + response[:reason] = detail.reason + else + response[:value] = @client.variation(params[:flagKey], params[:user], params[:defaultValue]) + end + + response + end + + def evaluate_all(params) + opts = {} + opts[:client_side_only] = params[:clientSideOnly] || false + opts[:with_reasons] = params[:withReasons] || false + opts[:details_only_for_tracked_flags] = params[:detailsOnlyForTrackedFlags] || false + + @client.all_flags_state(params[:user], opts) + end + + def track(params) + @client.track(params[:eventKey], params[:user], params[:data], params[:metricValue]) + end + + def identify(params) + @client.identify(params[:user]) + end + + def alias(params) + @client.alias(params[:user], params[:previousUser]) + end + + def flush_events + @client.flush + end + + def log + @log + end + + def close + @client.close + @log.info("Test ended") + end +end diff --git a/contract-tests/service.rb b/contract-tests/service.rb new file mode 100644 index 00000000..54cc0b73 --- /dev/null +++ b/contract-tests/service.rb @@ -0,0 +1,112 @@ +require 'launchdarkly-server-sdk' +require 'json' +require 'logger' +require 'net/http' +require 'sinatra' + +require './client_entity.rb' + +configure :development do + disable :show_exceptions +end + +$log = Logger.new(STDOUT) +$log.formatter = proc {|severity, datetime, progname, msg| + "[GLOBAL] #{datetime.strftime('%Y-%m-%d %H:%M:%S.%3N')} #{severity} #{progname} #{msg}\n" +} + +set :port, 9000 +set :logging, false + +clients = {} +clientCounter = 0 + +get '/' do + { + capabilities: [ + 'server-side', + 'all-flags-with-reasons', + 'all-flags-client-side-only', + 'all-flags-details-only-for-tracked-flags', + ] + }.to_json +end + +delete '/' do + $log.info("Test service has told us to exit") + Thread.new { sleep 1; exit } + return 204 +end + +post '/' do + opts = JSON.parse(request.body.read, :symbolize_names => true) + tag = "[#{opts[:tag]}]" + + clientCounter += 1 + clientId = clientCounter.to_s + + log = Logger.new(STDOUT) + log.formatter = proc {|severity, datetime, progname, msg| + "#{tag} #{datetime.strftime('%Y-%m-%d %H:%M:%S.%3N')} #{severity} #{progname} #{msg}\n" + } + + log.info("Starting client") + log.debug("Parameters: #{opts}") + + client = ClientEntity.new(log, opts[:configuration]) + + if !client.initialized? && opts[:configuration][:initCanFail] == false + client.close() + return [500, nil, "Failed to initialize"] + end + + clientResourceUrl = "/clients/#{clientId}" + clients[clientId] = client + return [201, {'Location' => clientResourceUrl}, nil] +end + +post '/clients/:id' do |clientId| + client = clients[clientId] + return 404 if client.nil? + + params = JSON.parse(request.body.read, :symbolize_names => true) + + client.log.info("Processing request for client #{clientId}") + client.log.debug("Parameters: #{params}") + + case params[:command] + when "evaluate" + response = client.evaluate(params[:evaluate]) + return [200, nil, response.to_json] + when "evaluateAll" + response = {:state => client.evaluate_all(params[:evaluateAll])} + return [200, nil, response.to_json] + when "customEvent" + client.track(params[:customEvent]) + return 201 + when "identifyEvent" + client.identify(params[:identifyEvent]) + return 201 + when "aliasEvent" + client.alias(params[:aliasEvent]) + return 201 + when "flushEvents" + client.flush_events + return 201 + end + + return [400, nil, {:error => "Unknown command requested"}.to_json] +end + +delete '/clients/:id' do |clientId| + client = clients[clientId] + return 404 if client.nil? + clients.delete(clientId) + client.close + + return 204 +end + +error do + env['sinatra.error'].message +end diff --git a/launchdarkly-server-sdk.gemspec b/launchdarkly-server-sdk.gemspec index 67125390..bc4492a6 100644 --- a/launchdarkly-server-sdk.gemspec +++ b/launchdarkly-server-sdk.gemspec @@ -22,7 +22,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 2.5.0" spec.add_development_dependency "aws-sdk-dynamodb", "~> 1.57" - spec.add_development_dependency "bundler", "2.2.10" + spec.add_development_dependency "bundler", "2.2.33" spec.add_development_dependency "rspec", "~> 3.10" spec.add_development_dependency "diplomat", "~> 2.4.2" spec.add_development_dependency "redis", "~> 4.2" diff --git a/lib/ldclient-rb/config.rb b/lib/ldclient-rb/config.rb index 3cfbf882..ed33e08b 100644 --- a/lib/ldclient-rb/config.rb +++ b/lib/ldclient-rb/config.rb @@ -21,6 +21,7 @@ class Config # @option opts [Integer] :capacity (10000) See {#capacity}. # @option opts [Float] :flush_interval (30) See {#flush_interval}. # @option opts [Float] :read_timeout (10) See {#read_timeout}. + # @option opts [Float] :initial_reconnect_delay (1) See {#initial_reconnect_delay}. # @option opts [Float] :connect_timeout (2) See {#connect_timeout}. # @option opts [Object] :cache_store See {#cache_store}. # @option opts [Object] :feature_store See {#feature_store}. @@ -54,6 +55,7 @@ def initialize(opts = {}) @flush_interval = opts[:flush_interval] || Config.default_flush_interval @connect_timeout = opts[:connect_timeout] || Config.default_connect_timeout @read_timeout = opts[:read_timeout] || Config.default_read_timeout + @initial_reconnect_delay = opts[:initial_reconnect_delay] || Config.default_initial_reconnect_delay @feature_store = opts[:feature_store] || Config.default_feature_store @stream = opts.has_key?(:stream) ? opts[:stream] : Config.default_stream @use_ldd = opts.has_key?(:use_ldd) ? opts[:use_ldd] : Config.default_use_ldd @@ -180,6 +182,13 @@ def offline? # attr_reader :read_timeout + # + # The initial delay before reconnecting after an error in the SSE client. + # This only applies to the streaming connection. + # @return [Float] + # + attr_reader :initial_reconnect_delay + # # The connect timeout for network connections in seconds. # @return [Float] @@ -395,6 +404,14 @@ def self.default_read_timeout 10 end + # + # The default value for {#initial_reconnect_delay}. + # @return [Float] 1 + # + def self.default_initial_reconnect_delay + 1 + end + # # The default value for {#connect_timeout}. # @return [Float] 10 diff --git a/lib/ldclient-rb/flags_state.rb b/lib/ldclient-rb/flags_state.rb index 496ad61b..50fcec88 100644 --- a/lib/ldclient-rb/flags_state.rb +++ b/lib/ldclient-rb/flags_state.rb @@ -16,21 +16,32 @@ def initialize(valid) # Used internally to build the state map. # @private - def add_flag(flag, value, variation, reason = nil, details_only_if_tracked = false) - key = flag[:key] - @flag_values[key] = value + def add_flag(flag_state, with_reasons, details_only_if_tracked) + key = flag_state[:key] + @flag_values[key] = flag_state[:value] meta = {} - with_details = !details_only_if_tracked || flag[:trackEvents] - if !with_details && flag[:debugEventsUntilDate] - with_details = flag[:debugEventsUntilDate] > Impl::Util::current_time_millis + + omit_details = false + if details_only_if_tracked + if !flag_state[:trackEvents] && !flag_state[:trackReason] && !(flag_state[:debugEventsUntilDate] && flag_state[:debugEventsUntilDate] > Impl::Util::current_time_millis) + omit_details = true + end + end + + reason = (!with_reasons and !flag_state[:trackReason]) ? nil : flag_state[:reason] + + if !reason.nil? && !omit_details + meta[:reason] = reason end - if with_details - meta[:version] = flag[:version] - meta[:reason] = reason if !reason.nil? + + if !omit_details + meta[:version] = flag_state[:version] end - meta[:variation] = variation if !variation.nil? - meta[:trackEvents] = true if flag[:trackEvents] - meta[:debugEventsUntilDate] = flag[:debugEventsUntilDate] if flag[:debugEventsUntilDate] + + meta[:variation] = flag_state[:variation] if !flag_state[:variation].nil? + meta[:trackEvents] = true if flag_state[:trackEvents] + meta[:trackReason] = true if flag_state[:trackReason] + meta[:debugEventsUntilDate] = flag_state[:debugEventsUntilDate] if flag_state[:debugEventsUntilDate] @flag_metadata[key] = meta end diff --git a/lib/ldclient-rb/impl/evaluator_operators.rb b/lib/ldclient-rb/impl/evaluator_operators.rb index 77b0960b..e54368e9 100644 --- a/lib/ldclient-rb/impl/evaluator_operators.rb +++ b/lib/ldclient-rb/impl/evaluator_operators.rb @@ -89,7 +89,7 @@ def self.user_value(user, attribute) private - BUILTINS = Set[:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous] + BUILTINS = Set[:key, :secondary, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous] NUMERIC_VERSION_COMPONENTS_REGEX = Regexp.new("^[0-9.]*") private_constant :BUILTINS diff --git a/lib/ldclient-rb/impl/event_factory.rb b/lib/ldclient-rb/impl/event_factory.rb index 691339d7..19b4e474 100644 --- a/lib/ldclient-rb/impl/event_factory.rb +++ b/lib/ldclient-rb/impl/event_factory.rb @@ -13,7 +13,7 @@ def initialize(with_reasons) end def new_eval_event(flag, user, detail, default_value, prereq_of_flag = nil) - add_experiment_data = is_experiment(flag, detail.reason) + add_experiment_data = self.class.is_experiment(flag, detail.reason) e = { kind: 'feature', key: flag[:key], @@ -91,17 +91,7 @@ def new_custom_event(event_name, user, data, metric_value) e end - private - - def context_to_context_kind(user) - if !user.nil? && user[:anonymous] - return "anonymousUser" - else - return "user" - end - end - - def is_experiment(flag, reason) + def self.is_experiment(flag, reason) return false if !reason if reason.in_experiment @@ -121,6 +111,13 @@ def is_experiment(flag, reason) false end + private def context_to_context_kind(user) + if !user.nil? && user[:anonymous] + return "anonymousUser" + else + return "user" + end + end end end end diff --git a/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb b/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb index 4085e53d..7244fc9b 100644 --- a/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +++ b/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb @@ -35,7 +35,7 @@ def initialize(table_name, opts) @client = Aws::DynamoDB::Client.new(opts[:dynamodb_opts] || {}) end - @logger.info("${description}: using DynamoDB table \"#{table_name}\"") + @logger.info("#{description}: using DynamoDB table \"#{table_name}\"") end def stop diff --git a/lib/ldclient-rb/integrations/consul.rb b/lib/ldclient-rb/integrations/consul.rb index 020c31b4..b3947047 100644 --- a/lib/ldclient-rb/integrations/consul.rb +++ b/lib/ldclient-rb/integrations/consul.rb @@ -36,7 +36,7 @@ def self.default_prefix # @option opts [Integer] :capacity (1000) maximum number of items in the cache # @return [LaunchDarkly::Interfaces::FeatureStore] a feature store object # - def self.new_feature_store(opts, &block) + def self.new_feature_store(opts = {}) core = LaunchDarkly::Impl::Integrations::Consul::ConsulFeatureStoreCore.new(opts) return LaunchDarkly::Integrations::Util::CachingStoreWrapper.new(core, opts) end diff --git a/lib/ldclient-rb/integrations/dynamodb.rb b/lib/ldclient-rb/integrations/dynamodb.rb index 229a64af..29aedcdb 100644 --- a/lib/ldclient-rb/integrations/dynamodb.rb +++ b/lib/ldclient-rb/integrations/dynamodb.rb @@ -46,7 +46,7 @@ module DynamoDB # @option opts [Integer] :capacity (1000) maximum number of items in the cache # @return [LaunchDarkly::Interfaces::FeatureStore] a feature store object # - def self.new_feature_store(table_name, opts) + def self.new_feature_store(table_name, opts = {}) core = LaunchDarkly::Impl::Integrations::DynamoDB::DynamoDBFeatureStoreCore.new(table_name, opts) LaunchDarkly::Integrations::Util::CachingStoreWrapper.new(core, opts) end diff --git a/lib/ldclient-rb/integrations/redis.rb b/lib/ldclient-rb/integrations/redis.rb index 6fed732d..95147286 100644 --- a/lib/ldclient-rb/integrations/redis.rb +++ b/lib/ldclient-rb/integrations/redis.rb @@ -58,7 +58,7 @@ def self.default_prefix # lifecycle to be independent of the SDK client # @return [LaunchDarkly::Interfaces::FeatureStore] a feature store object # - def self.new_feature_store(opts) + def self.new_feature_store(opts = {}) return RedisFeatureStore.new(opts) end diff --git a/lib/ldclient-rb/ldclient.rb b/lib/ldclient-rb/ldclient.rb index a8719773..b5e5ead9 100644 --- a/lib/ldclient-rb/ldclient.rb +++ b/lib/ldclient-rb/ldclient.rb @@ -65,7 +65,7 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5) get_segment = lambda { |key| @store.get(SEGMENTS, key) } get_big_segments_membership = lambda { |key| @big_segment_store_manager.get_user_membership(key) } @evaluator = LaunchDarkly::Impl::Evaluator.new(get_flag, get_segment, get_big_segments_membership, @config.logger) - + if !@config.offline? && @config.send_events && !@config.diagnostic_opt_out? diagnostic_accumulator = Impl::DiagnosticAccumulator.new(Impl::DiagnosticAccumulator.create_diagnostic_id(sdk_key)) else @@ -178,7 +178,7 @@ def initialized? # Other supported user attributes include IP address, country code, and an arbitrary hash of # custom attributes. For more about the supported user properties and how they work in # LaunchDarkly, see [Targeting users](https://docs.launchdarkly.com/home/flags/targeting-users). - # + # # The optional `:privateAttributeNames` user property allows you to specify a list of # attribute names that should not be sent back to LaunchDarkly. # [Private attributes](https://docs.launchdarkly.com/home/users/attributes#creating-private-user-attributes) @@ -248,8 +248,8 @@ def variation_detail(key, user, default) # @return [void] # def identify(user) - if !user || user[:key].nil? - @config.logger.warn("Identify called with nil user or nil user key!") + if !user || user[:key].nil? || user[:key].empty? + @config.logger.warn("Identify called with nil user or empty user key!") return end sanitize_user(user) @@ -338,6 +338,15 @@ def all_flags(user) def all_flags_state(user, options={}) return FeatureFlagsState.new(false) if @config.offline? + if !initialized? + if @store.initialized? + @config.logger.warn { "Called all_flags_state before client initialization; using last known values from data store" } + else + @config.logger.warn { "Called all_flags_state before client initialization. Data store not available; returning empty state" } + return FeatureFlagsState.new(false) + end + end + unless user && !user[:key].nil? @config.logger.error { "[LDClient] User and user key must be specified in all_flags_state" } return FeatureFlagsState.new(false) @@ -359,14 +368,25 @@ def all_flags_state(user, options={}) next end begin - result = @evaluator.evaluate(f, user, @event_factory_default) - state.add_flag(f, result.detail.value, result.detail.variation_index, with_reasons ? result.detail.reason : nil, - details_only_if_tracked) + detail = @evaluator.evaluate(f, user, @event_factory_default).detail rescue => exn + detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_EXCEPTION)) Util.log_exception(@config.logger, "Error evaluating flag \"#{k}\" in all_flags_state", exn) - state.add_flag(f, nil, nil, with_reasons ? EvaluationReason::error(EvaluationReason::ERROR_EXCEPTION) : nil, - details_only_if_tracked) end + + requires_experiment_data = EventFactory.is_experiment(f, detail.reason) + flag_state = { + key: f[:key], + value: detail.value, + variation: detail.variation_index, + reason: detail.reason, + version: f[:version], + trackEvents: f[:trackEvents] || requires_experiment_data, + trackReason: requires_experiment_data, + debugEventsUntilDate: f[:debugEventsUntilDate], + } + + state.add_flag(flag_state, with_reasons, details_only_if_tracked) end state diff --git a/lib/ldclient-rb/stream.rb b/lib/ldclient-rb/stream.rb index 64275b39..211e6321 100644 --- a/lib/ldclient-rb/stream.rb +++ b/lib/ldclient-rb/stream.rb @@ -47,7 +47,8 @@ def start headers: headers, read_timeout: READ_TIMEOUT_SECONDS, logger: @config.logger, - socket_factory: @config.socket_factory + socket_factory: @config.socket_factory, + reconnect_time: @config.initial_reconnect_delay } log_connection_started @es = SSE::Client.new(@config.stream_uri + "/all", **opts) do |conn| diff --git a/lib/ldclient-rb/util.rb b/lib/ldclient-rb/util.rb index 7bd56959..5aac9d1e 100644 --- a/lib/ldclient-rb/util.rb +++ b/lib/ldclient-rb/util.rb @@ -24,6 +24,15 @@ def self.new_http_client(uri_s, config) if config.socket_factory http_client_options["socket_class"] = config.socket_factory end + proxy = URI.parse(uri_s).find_proxy + if !proxy.nil? + http_client_options["proxy"] = { + proxy_address: proxy.host, + proxy_port: proxy.port, + proxy_username: proxy.user, + proxy_password: proxy.password + } + end return HTTP::Client.new(http_client_options) .timeout({ read: config.read_timeout, diff --git a/spec/event_sender_spec.rb b/spec/event_sender_spec.rb index 31bfb6ae..72d19197 100644 --- a/spec/event_sender_spec.rb +++ b/spec/event_sender_spec.rb @@ -14,7 +14,11 @@ module Impl let(:fake_data) { '{"things":[]}' } def make_sender(server) - subject.new(sdk_key, Config.new(events_uri: server.base_uri.to_s, logger: $null_log), nil, 0.1) + make_sender_with_events_uri(server.base_uri.to_s) + end + + def make_sender_with_events_uri(events_uri) + subject.new(sdk_key, Config.new(events_uri: events_uri, logger: $null_log), nil, 0.1) end def with_sender_and_server @@ -105,25 +109,30 @@ def with_sender_and_server end it "can use a proxy server" do - with_server do |server| - server.setup_ok_response("/bulk", "") - - with_server(StubProxyServer.new) do |proxy| - begin - ENV["http_proxy"] = proxy.base_uri.to_s + fake_target_uri = "http://request-will-not-really-go-here" + # Instead of a real proxy server, we just create a basic test HTTP server that + # pretends to be a proxy. The proof that the proxy logic is working correctly is + # that the request goes to that server, instead of to fake_target_uri. We can't + # use a real proxy that really forwards requests to another test server, because + # that test server would be at localhost, and proxy environment variables are + # ignored if the target is localhost. + with_server do |proxy| + proxy.setup_ok_response("/bulk", "") - es = make_sender(server) + begin + ENV["http_proxy"] = proxy.base_uri.to_s - result = es.send_event_data(fake_data, "", false) - - expect(result.success).to be true + es = make_sender_with_events_uri(fake_target_uri) - req, body = server.await_request_with_body - expect(body).to eq fake_data - ensure - ENV["http_proxy"] = nil - end + result = es.send_event_data(fake_data, "", false) + + expect(result.success).to be true + ensure + ENV["http_proxy"] = nil end + + req, body = proxy.await_request_with_body + expect(body).to eq fake_data end end diff --git a/spec/flags_state_spec.rb b/spec/flags_state_spec.rb index bda55b11..323c6c31 100644 --- a/spec/flags_state_spec.rb +++ b/spec/flags_state_spec.rb @@ -6,8 +6,8 @@ it "can get flag value" do state = subject.new(true) - flag = { key: 'key' } - state.add_flag(flag, 'value', 1) + flag_state = { key: 'key', value: 'value', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + state.add_flag(flag_state, false, false) expect(state.flag_value('key')).to eq 'value' end @@ -20,21 +20,21 @@ it "can be converted to values map" do state = subject.new(true) - flag1 = { key: 'key1' } - flag2 = { key: 'key2' } - state.add_flag(flag1, 'value1', 0) - state.add_flag(flag2, 'value2', 1) + flag_state1 = { key: 'key1', value: 'value1', variation: 0, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + flag_state2 = { key: 'key2', value: 'value2', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + state.add_flag(flag_state1, false, false) + state.add_flag(flag_state2, false, false) expect(state.values_map).to eq({ 'key1' => 'value1', 'key2' => 'value2' }) end it "can be converted to JSON structure" do state = subject.new(true) - flag1 = { key: "key1", version: 100, offVariation: 0, variations: [ 'value1' ], trackEvents: false } - flag2 = { key: "key2", version: 200, offVariation: 1, variations: [ 'x', 'value2' ], trackEvents: true, debugEventsUntilDate: 1000 } - state.add_flag(flag1, 'value1', 0) - state.add_flag(flag2, 'value2', 1) - + flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + state.add_flag(flag_state1, false, false) + state.add_flag(flag_state2, false, false) + result = state.as_json expect(result).to eq({ 'key1' => 'value1', @@ -57,11 +57,11 @@ it "can be converted to JSON string" do state = subject.new(true) - flag1 = { key: "key1", version: 100, offVariation: 0, variations: [ 'value1' ], trackEvents: false } - flag2 = { key: "key2", version: 200, offVariation: 1, variations: [ 'x', 'value2' ], trackEvents: true, debugEventsUntilDate: 1000 } - state.add_flag(flag1, 'value1', 0) - state.add_flag(flag2, 'value2', 1) - + flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + state.add_flag(flag_state1, false, false) + state.add_flag(flag_state2, false, false) + object = state.as_json str = state.to_json expect(object.to_json).to eq(str) @@ -69,11 +69,11 @@ it "uses our custom serializer with JSON.generate" do state = subject.new(true) - flag1 = { key: "key1", version: 100, offVariation: 0, variations: [ 'value1' ], trackEvents: false } - flag2 = { key: "key2", version: 200, offVariation: 1, variations: [ 'x', 'value2' ], trackEvents: true, debugEventsUntilDate: 1000 } - state.add_flag(flag1, 'value1', 0) - state.add_flag(flag2, 'value2', 1) - + flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + state.add_flag(flag_state1, false, false) + state.add_flag(flag_state2, false, false) + stringFromToJson = state.to_json stringFromGenerate = JSON.generate(state) expect(stringFromGenerate).to eq(stringFromToJson) diff --git a/spec/impl/evaluator_operators_spec.rb b/spec/impl/evaluator_operators_spec.rb index ddf55cc7..5c447e6f 100644 --- a/spec/impl/evaluator_operators_spec.rb +++ b/spec/impl/evaluator_operators_spec.rb @@ -105,13 +105,13 @@ end describe "user_value" do - [:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous, :some_custom_attr].each do |attr| + [:key, :secondary, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous, :some_custom_attr].each do |attr| it "returns nil if property #{attr} is not defined" do expect(subject::user_value({}, attr)).to be nil end end - [:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name].each do |attr| + [:key, :secondary, :ip, :country, :email, :firstName, :lastName, :avatar, :name].each do |attr| it "gets string value of string property #{attr}" do expect(subject::user_value({ attr => 'x' }, attr)).to eq 'x' end diff --git a/spec/ldclient_evaluation_spec.rb b/spec/ldclient_evaluation_spec.rb index c63cb882..581f3256 100644 --- a/spec/ldclient_evaluation_spec.rb +++ b/spec/ldclient_evaluation_spec.rb @@ -301,6 +301,22 @@ module LaunchDarkly expect(state.values_map).to eq({}) end end + + it "returns empty state if store is not initialize" do + wait = double + expect(wait).to receive(:wait).at_least(:once) + + source = double + expect(source).to receive(:start).at_least(:once).and_return(wait) + expect(source).to receive(:stop).at_least(:once).and_return(wait) + expect(source).to receive(:initialized?).at_least(:once).and_return(false) + store = LaunchDarkly::InMemoryFeatureStore.new + with_client(test_config(store: store, data_source: source)) do |offline_client| + state = offline_client.all_flags_state({ key: 'userkey' }) + expect(state.valid?).to be false + expect(state.values_map).to eq({}) + end + end end end end diff --git a/spec/ldclient_events_spec.rb b/spec/ldclient_events_spec.rb index 86eaa77d..b2afcc13 100644 --- a/spec/ldclient_events_spec.rb +++ b/spec/ldclient_events_spec.rb @@ -196,13 +196,13 @@ def event_processor(client) end end - it "does not send event, and logs warning, if user key is nil" do + it "does not send event, and logs warning, if user key is blank" do logger = double().as_null_object with_client(test_config(logger: logger)) do |client| expect(event_processor(client)).not_to receive(:add_event) expect(logger).to receive(:warn) - client.identify({ key: nil }) + client.identify({ key: "" }) end end end diff --git a/spec/requestor_spec.rb b/spec/requestor_spec.rb index c224b22a..65ec7ed3 100644 --- a/spec/requestor_spec.rb +++ b/spec/requestor_spec.rb @@ -189,19 +189,24 @@ def with_requestor(base_uri, opts = {}) end it "can use a proxy server" do + fake_target_uri = "http://request-will-not-really-go-here" + # Instead of a real proxy server, we just create a basic test HTTP server that + # pretends to be a proxy. The proof that the proxy logic is working correctly is + # that the request goes to that server, instead of to fake_target_uri. We can't + # use a real proxy that really forwards requests to another test server, because + # that test server would be at localhost, and proxy environment variables are + # ignored if the target is localhost. expected_data = { flags: { flagkey: { key: "flagkey" } } } - with_server do |server| - server.setup_ok_response("/sdk/latest-all", expected_data.to_json, "application/json", { "etag" => "x" }) - with_server(StubProxyServer.new) do |proxy| - begin - ENV["http_proxy"] = proxy.base_uri.to_s - with_requestor(server.base_uri.to_s) do |requestor| - data = requestor.request_all_data - expect(data).to eq(LaunchDarkly::Impl::Model.make_all_store_data(expected_data)) - end - ensure - ENV["http_proxy"] = nil + with_server do |proxy| + proxy.setup_ok_response("/sdk/latest-all", expected_data.to_json, "application/json", { "etag" => "x" }) + begin + ENV["http_proxy"] = proxy.base_uri.to_s + with_requestor(fake_target_uri) do |requestor| + data = requestor.request_all_data + expect(data).to eq(LaunchDarkly::Impl::Model.make_all_store_data(expected_data)) end + ensure + ENV["http_proxy"] = nil end end end