diff --git a/README.md b/README.md index c6c68a34..b3eab17c 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,15 @@ LaunchDarkly SDK for Ruby Quick setup ----------- -1. Install the Ruby SDK with `gem` +0. Install the Ruby SDK with `gem` gem install ldclient-rb +1. Require the LaunchDarkly client: + + require 'ldclient-rb' + + 2. Create a new LDClient with your API key: client = LaunchDarkly::LDClient.new("your_api_key") diff --git a/lib/ldclient-rb/config.rb b/lib/ldclient-rb/config.rb index e813efab..a0823649 100644 --- a/lib/ldclient-rb/config.rb +++ b/lib/ldclient-rb/config.rb @@ -1,15 +1,102 @@ +require 'logger' + module LaunchDarkly + + # + # This class exposes advanced configuration options for the LaunchDarkly client library. Most users + # will not need to use a custom configuration-- the default configuration sets sane defaults for most use cases. + # + # class Config - def initialize(base_uri) - @base_uri = base_uri + # + # Constructor for creating custom LaunchDarkly configurations. + # + # @param opts [Hash] the configuration options + # @option opts [Logger] :logger A logger to use for messages from the LaunchDarkly client. Defaults to the Rails logger in a Rails environment, or stdout otherwise. + # @option opts [String] :base_uri ("https://app.launchdarkly.com") The base URL for the LaunchDarkly server. Most users should use the default value. + # @option opts [Integer] :capacity (10000) The capacity of the events buffer. The client buffers up to this many events in memory before flushing. If the capacity is exceeded before the buffer is flushed, events will be discarded. + # @option opts [Integer] :flush_interval (30) The number of seconds between flushes of the event buffer. + # @option opts [Object] :store A cache store for the Faraday HTTP caching library. Defaults to the Rails cache in a Rails environment, or a thread-safe in-memory store otherwise. + # + # @return [type] [description] + def initialize(opts = {}) + @base_uri = opts[:base_uri] || Config.default_base_uri + @capacity = opts[:capacity] || Config.default_capacity + @logger = opts[:logger] || Config.default_logger + @store = opts[:store] || Config.default_store + @flush_interval = opts[:flush_interval] || Config.default_flush_interval end + # + # The base URL for the LaunchDarkly server. + # + # @return [String] The configured base URL for the LaunchDarkly server. def base_uri @base_uri end + # + # The number of seconds between flushes of the event buffer. Decreasing the flush interval means + # that the event buffer is less likely to reach capacity. + # + # @return [Integer] The configured number of seconds between flushes of the event buffer. + def flush_interval + @flush_interval + end + + # + # The configured logger for the LaunchDarkly client. The client library uses the log to + # print warning and error messages. + # + # @return [Logger] The configured logger + def logger + @logger + end + + # + # The capacity of the events buffer. The client buffers up to this many events in memory before flushing. If the capacity is exceeded before the buffer is flushed, events will be discarded. + # Increasing the capacity means that events are less likely to be discarded, at the cost of consuming more memory. + # + # @return [Integer] The configured capacity of the event buffer + def capacity + @capacity + end + + # + # The store for the Faraday HTTP caching library. Stores should respond to 'read' and 'write' requests. + # + # @return [Object] The configured store for the Faraday HTTP caching library. + def store + @store + end + + # + # The default LaunchDarkly client configuration. This configuration sets reasonable defaults for most users. + # + # @return [Config] The default LaunchDarkly configuration. def self.default - Config.new('https://app.launchdarkly.com') + Config.new() end + + def self.default_capacity + 10000 + end + + def self.default_base_uri + "https://app.launchdarkly.com" + end + + def self.default_store + defined?(Rails) && Rails.respond_to?(:cache) ? Rails.cache : ThreadSafeMemoryStore.new + end + + def self.default_flush_interval + 30 + end + + def self.default_logger + defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : ::Logger.new($stdout) + end + end end \ No newline at end of file diff --git a/lib/ldclient-rb/ldclient.rb b/lib/ldclient-rb/ldclient.rb index f107265f..c59e07cb 100644 --- a/lib/ldclient-rb/ldclient.rb +++ b/lib/ldclient-rb/ldclient.rb @@ -1,26 +1,130 @@ require 'faraday/http_cache' require 'json' require 'digest/sha1' +require 'thread' +require 'logger' module LaunchDarkly + # + # A client for the LaunchDarkly API. Client instances are thread-safe. Users + # should create a single client instance for the lifetime of the application. + # + # class LDClient - LONG_SCALE = Float(0xFFFFFFFFFFFFFFF) - + # + # Creates a new client instance that connects to LaunchDarkly. A custom + # configuration parameter can also supplied to specify advanced options, + # but for most use cases, the default configuration is appropriate. + # + # + # @param api_key [String] the API key for your LaunchDarkly account + # @param config [Config] an optional client configuration object + # + # @return [LDClient] The LaunchDarkly client instance def initialize(api_key, config = Config.default) - store = ThreadSafeMemoryStore.new + @queue = Queue.new @api_key = api_key @config = config @client = Faraday.new do |builder| - builder.use :http_cache, store: store + builder.use :http_cache, store: @config.store builder.adapter Faraday.default_adapter end + + Thread.new do + while true do + events = [] + num_events = @queue.length() + num_events.times do + events << @queue.pop() + end + + if !events.empty?() + res = + @client.post (@config.base_uri + "/api/events/bulk") do |req| + req.headers['Authorization'] = 'api_key ' + @api_key + req.headers['User-Agent'] = 'RubyClient/' + LaunchDarkly::VERSION + req.headers['Content-Type'] = 'application/json' + req.body = events.to_json + end + if res.status != 200 + @config.logger.error("Unexpected status code while processing events: " + res.status) + end + end + + sleep(@config.flush_interval) + end + end + end + # + # Calculates the value of a feature flag for a given user. At a minimum, the user hash + # should contain a +:key+ . + # + # @example Basic user hash + # {:key => "user@example.com"} + # + # For authenticated users, the +:key+ should be the unique identifier for your user. For anonymous users, + # the +:key+ should be a session identifier or cookie. In either case, the only requirement is that the key + # is unique to a user. + # + # You can also pass IP addresses and country codes in the user hash. + # + # @example More complete user hash + # {:key => "user@example.com", :ip => "127.0.0.1", :country => "US"} + # + # Countries should be sent as ISO 3166-1 alpha-2 codes. + # + # The user hash can contain arbitrary custom attributes stored in a +:custom+ sub-hash: + # + # @example A user hash with custom attributes + # {:key => "user@example.com", :custom => {:customer_rank => 1000, :groups => ["google", "microsoft"]}} + # + # Attribute values in the custom hash can be integers, booleans, strings, or lists of integers, booleans, or strings. + # + # @param key [String] the unique feature key for the feature flag, as shown on the LaunchDarkly dashboard + # @param user [Hash] a hash containing parameters for the end user requesting the flag + # @param default=false [Boolean] the default value of the flag + # + # @return [Boolean] whether or not the flag should be enabled, or the default value if the flag is disabled on the LaunchDarkly control panel def get_flag?(key, user, default=false) + begin + value = get_flag_int(key, user, default) + add_event({:kind => 'feature', :key => key, :user => user, :value => value}) + return value + rescue StandardError => error + @config.logger.error("Unhandled exception in get_flag: " + error.message) + default + end + end + + def add_event(event) + if @queue.length() < @config.capacity + event[:creationDate] = (Time.now.to_f * 1000).to_i + @queue.push(event) + else + @config.logger.warn("Exceeded event queue capacity. Increase capacity to avoid dropping events.") + end + end + + # + # Tracks that a user performed an event + # + # @param event_name [String] The name of the event + # @param user [Hash] The user that performed the event. This should be the same user hash used in calls to {#get_flag?} + # @param data [Hash] A hash containing any additional data associated with the event + # + # @return [void] + def send_event(event_name, user, data) + add_event({:kind => 'custom', :key => event_name, :user => user, :data => data }) + end + + def get_flag_int(key, user, default) unless user + @config.logger.error("Must specify user") return default end @@ -30,7 +134,23 @@ def get_flag?(key, user, default=false) req.headers['User-Agent'] = 'RubyClient/' + LaunchDarkly::VERSION end - feature = JSON.parse(res.body) + if res.status == 401 + @config.logger.error("Invalid API key") + return default + end + + if res.status == 404 + @config.logger.error("Unknown feature key: " + key) + return default + end + + if res.status != 200 + @config.logger.error("Unexpected status code " + res.status) + return default + end + + + feature = JSON.parse(res.body, :symbolize_names => true) val = evaluate(feature, user) @@ -38,43 +158,44 @@ def get_flag?(key, user, default=false) end def param_for_user(feature, user) - if user.has_key? 'key' - id_hash = user['key'] + if user.has_key? :key + id_hash = user[:key] else return nil end - if user.has_key? 'secondary' - id_hash += '.' + user['secondary'] + if user.has_key? :secondary + id_hash += '.' + user[:secondary] end - hash_key = "%s.%s.%s" % [feature['key'], feature['salt'], id_hash] + hash_key = "%s.%s.%s" % [feature[:key], feature[:salt], id_hash] + hash_val = (Digest::SHA1.hexdigest(hash_key))[0..14] - return hash_val.to_i(16) / LONG_SCALE + return hash_val.to_i(16) / Float(0xFFFFFFFFFFFFFFF) end def match_target?(target, user) - attrib = target['attribute'] + attrib = target[:attribute].to_sym - if attrib == 'key' or attrib == 'ip' or attrib == 'country' + if attrib == :key or attrib == :ip or attrib == :country if user[attrib] u_value = user[attrib] - return target['values'].include? u_value + return target[:values].include? u_value else return false end else # custom attribute - unless user.has_key? 'custom' + unless user.has_key? :custom return false end - unless user['custom'].include? attrib + unless user[:custom].include? attrib return false end - u_value = user['custom'][attrib] + u_value = user[:custom][attrib] if u_value.is_a? String or u_value.is_a? Numeric - return target['values'].include? u_value + return target[:values].include? u_value elsif u_value.is_a? Array - return ! ((target['values'] & u_value).empty?) + return ! ((target[:values] & u_value).empty?) end return false @@ -83,7 +204,7 @@ def match_target?(target, user) end def match_variation?(variation, user) - variation['targets'].each do |target| + variation[:targets].each do |target| if match_target?(target, user) return true end @@ -92,7 +213,7 @@ def match_variation?(variation, user) end def evaluate(feature, user) - unless feature['on'] + unless feature[:on] return nil end @@ -102,18 +223,18 @@ def evaluate(feature, user) return nil end - feature['variations'].each do |variation| + feature[:variations].each do |variation| if match_variation?(variation, user) - return variation['value'] + return variation[:value] end end total = 0.0 - feature['variations'].each do |variation| - total += variation['weight'].to_f / 100.0 + feature[:variations].each do |variation| + total += variation[:weight].to_f / 100.0 if param < total - return variation['value'] + return variation[:value] end end @@ -121,7 +242,7 @@ def evaluate(feature, user) end - private :param_for_user, :match_target?, :match_variation?, :evaluate + private :add_event, :get_flag_int, :param_for_user, :match_target?, :match_variation?, :evaluate end diff --git a/lib/ldclient-rb/store.rb b/lib/ldclient-rb/store.rb index d8135a22..ab144026 100644 --- a/lib/ldclient-rb/store.rb +++ b/lib/ldclient-rb/store.rb @@ -1,15 +1,38 @@ require 'thread_safe' module LaunchDarkly + + # A thread-safe in-memory store suitable for use + # with the Faraday caching HTTP client. Uses the + # Threadsafe gem as the underlying cache. + # + # @see https://github.com/plataformatec/faraday-http-cache + # @see https://github.com/ruby-concurrency/thread_safe + # class ThreadSafeMemoryStore + # + # Default constructor + # + # @return [ThreadSafeMemoryStore] a new store def initialize @cache = ThreadSafe::Cache.new end + # + # Read a value from the cache + # @param key [Object] the cache key + # + # @return [Object] the cache value def read(key) @cache[key] end + # + # Store a value in the cache + # @param key [Object] the cache key + # @param value [Object] the value to associate with the key + # + # @return [Object] the value def write(key, value) @cache[key] = value end diff --git a/lib/ldclient-rb/version.rb b/lib/ldclient-rb/version.rb index def1bbcb..e9803d16 100644 --- a/lib/ldclient-rb/version.rb +++ b/lib/ldclient-rb/version.rb @@ -1,3 +1,3 @@ module LaunchDarkly - VERSION = "0.0.2" + VERSION = "0.0.5" end