require "ldclient-rb/impl/diagnostic_events" require "ldclient-rb/impl/event_factory" require "ldclient-rb/impl/store_client_wrapper" require "concurrent/atomics" require "digest/sha1" require "logger" require "benchmark" require "json" require "openssl" module LaunchDarkly # # A client for LaunchDarkly. Client instances are thread-safe. Users # should create a single client instance for the lifetime of the application. # class LDClient include Evaluation include Impl # # 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. # # The client will immediately attempt to connect to LaunchDarkly and retrieve # your feature flag data. If it cannot successfully do so within the time limit # specified by `wait_for_sec`, the constructor will return a client that is in # an uninitialized state. See {#initialized?} for more details. # # @param sdk_key [String] the SDK key for your LaunchDarkly account # @param config [Config] an optional client configuration object # @param wait_for_sec [Float] maximum time (in seconds) to wait for initialization # # @return [LDClient] The LaunchDarkly client instance # def initialize(sdk_key, config = Config.default, wait_for_sec = 5) # Note that sdk_key is normally a required parameter, and a nil value would cause the SDK to # fail in most configurations. However, there are some configurations where it would be OK # (offline = true, *or* we are using LDD mode or the file data source and events are disabled # so we're not connecting to any LD services) so rather than try to check for all of those # up front, we will let the constructors for the data source implementations implement this # fail-fast as appropriate, and just check here for the part regarding events. if !config.offline? && config.send_events raise ArgumentError, "sdk_key must not be nil" if sdk_key.nil? end @sdk_key = sdk_key @event_factory_default = EventFactory.new(false) @event_factory_with_reasons = EventFactory.new(true) # We need to wrap the feature store object with a FeatureStoreClientWrapper in order to add # some necessary logic around updates. Unfortunately, we have code elsewhere that accesses # the feature store through the Config object, so we need to make a new Config that uses # the wrapped store. @store = Impl::FeatureStoreClientWrapper.new(config.feature_store) updated_config = config.clone updated_config.instance_variable_set(:@feature_store, @store) @config = updated_config if !@config.offline? && @config.send_events && !@config.diagnostic_opt_out? diagnostic_accumulator = Impl::DiagnosticAccumulator.new(Impl::DiagnosticAccumulator.create_diagnostic_id(sdk_key)) else diagnostic_accumulator = nil end if @config.offline? || !@config.send_events @event_processor = NullEventProcessor.new else @event_processor = EventProcessor.new(sdk_key, config, nil, diagnostic_accumulator) end if @config.use_ldd? @config.logger.info { "[LDClient] Started LaunchDarkly Client in LDD mode" } return # requestor and update processor are not used in this mode end data_source_or_factory = @config.data_source || self.method(:create_default_data_source) if data_source_or_factory.respond_to? :call # Currently, data source factories take two parameters unless they need to be aware of diagnostic_accumulator, in # which case they take three parameters. This will be changed in the future to use a less awkware mechanism. if data_source_or_factory.arity == 3 @data_source = data_source_or_factory.call(sdk_key, @config, diagnostic_accumulator) else @data_source = data_source_or_factory.call(sdk_key, @config) end else @data_source = data_source_or_factory end ready = @data_source.start if wait_for_sec > 0 ok = ready.wait(wait_for_sec) if !ok @config.logger.error { "[LDClient] Timeout encountered waiting for LaunchDarkly client initialization" } elsif !@data_source.initialized? @config.logger.error { "[LDClient] LaunchDarkly client initialization failed" } end end end # # Tells the client that all pending analytics events should be delivered as soon as possible. # # When the LaunchDarkly client generates analytics events (from {#variation}, {#variation_detail}, # {#identify}, or {#track}), they are queued on a worker thread. The event thread normally # sends all queued events to LaunchDarkly at regular intervals, controlled by the # {Config#flush_interval} option. Calling `flush` triggers a send without waiting for the # next interval. # # Flushing is asynchronous, so this method will return before it is complete. However, if you # call {#close}, events are guaranteed to be sent before that method returns. # def flush @event_processor.flush end # # @param key [String] the feature flag key # @param user [Hash] the user properties # @param default [Boolean] (false) the value to use if the flag cannot be evaluated # @return [Boolean] the flag value # @deprecated Use {#variation} instead. # def toggle?(key, user, default = false) @config.logger.warn { "[LDClient] toggle? is deprecated. Use variation instead" } variation(key, user, default) end # # Creates a hash string that can be used by the JavaScript SDK to identify a user. # For more information, see [Secure mode](https://docs.launchdarkly.com/docs/js-sdk-reference#section-secure-mode). # # @param user [Hash] the user properties # @return [String] a hash string # def secure_mode_hash(user) OpenSSL::HMAC.hexdigest("sha256", @sdk_key, user[:key].to_s) end # # Returns whether the client has been initialized and is ready to serve feature flag requests. # # If this returns false, it means that the client did not succeed in connecting to # LaunchDarkly within the time limit that you specified in the constructor. It could # still succeed in connecting at a later time (on another thread), or it could have # given up permanently (for instance, if your SDK key is invalid). In the meantime, # any call to {#variation} or {#variation_detail} will behave as follows: # # 1. It will check whether the feature store already contains data (that is, you # are using a database-backed store and it was populated by a previous run of this # application). If so, it will use the last known feature flag data. # # 2. Failing that, it will return the value that you specified for the `default` # parameter of {#variation} or {#variation_detail}. # # @return [Boolean] true if the client has been initialized # def initialized? @config.offline? || @config.use_ldd? || @data_source.initialized? end # # Determines the variation of a feature flag to present to a user. # # At a minimum, the user hash should contain a `:key`, which should be the unique # identifier for your user (or, for an anonymous user, a session identifier or # cookie). # # 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/docs/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/docs/private-user-attributes) # can also be configured globally in {Config}. # # @example Basic user hash # {key: "my-user-id"} # # @example More complete user hash # {key: "my-user-id", ip: "127.0.0.1", country: "US", custom: {customer_rank: 1000}} # # @example User with a private attribute # {key: "my-user-id", email: "email@example.com", privateAttributeNames: ["email"]} # # @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 the default value of the flag; this is used if there is an error # condition making it impossible to find or evaluate the flag # # @return the variation to show the user, or the default value if there's an an error # def variation(key, user, default) evaluate_internal(key, user, default, @event_factory_default).value end # # Determines the variation of a feature flag for a user, like {#variation}, but also # provides additional information about how this value was calculated. # # The return value of `variation_detail` is an {EvaluationDetail} object, which has # three properties: the result value, the positional index of this value in the flag's # list of variations, and an object describing the main reason why this value was # selected. See {EvaluationDetail} for more on these properties. # # Calling `variation_detail` instead of `variation` also causes the "reason" data to # be included in analytics events, if you are capturing detailed event data for this flag. # # For more information, see the reference guide on # [Evaluation reasons](https://docs.launchdarkly.com/v2.0/docs/evaluation-reasons). # # @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 the default value of the flag; this is used if there is an error # condition making it impossible to find or evaluate the flag # # @return [EvaluationDetail] an object describing the result # def variation_detail(key, user, default) evaluate_internal(key, user, default, @event_factory_with_reasons) end # # Registers the user. This method simply creates an analytics event containing the user # properties, so that LaunchDarkly will know about that user if it does not already. # # Calling {#variation} or {#variation_detail} also sends the user information to # LaunchDarkly (if events are enabled), so you only need to use {#identify} if you # want to identify the user without evaluating a flag. # # Note that event delivery is asynchronous, so the event may not actually be sent # until later; see {#flush}. # # @param user [Hash] The user to register; this can have all the same user properties # described in {#variation} # @return [void] # def identify(user) if !user || user[:key].nil? @config.logger.warn("Identify called with nil user or nil user key!") return end sanitize_user(user) @event_processor.add_event(@event_factory_default.new_identify_event(user)) end # # Tracks that a user performed an event. This method creates a "custom" analytics event # containing the specified event name (key), user properties, and optional data. # # Note that event delivery is asynchronous, so the event may not actually be sent # until later; see {#flush}. # # As of this version’s release date, the LaunchDarkly service does not support the `metricValue` # parameter. As a result, specifying `metricValue` will not yet produce any different behavior # from omitting it. Refer to the [SDK reference guide](https://docs.launchdarkly.com/docs/ruby-sdk-reference#section-track) # for the latest status. # # @param event_name [String] The name of the event # @param user [Hash] The user to register; this can have all the same user properties # described in {#variation} # @param data [Hash] An optional hash containing any additional data associated with the event # @param metric_value [Number] A numeric value used by the LaunchDarkly experimentation # feature in numeric custom metrics. Can be omitted if this event is used by only # non-numeric metrics. This field will also be returned as part of the custom event # for Data Export. # @return [void] # def track(event_name, user, data = nil, metric_value = nil) if !user || user[:key].nil? @config.logger.warn("Track called with nil user or nil user key!") return end sanitize_user(user) @event_processor.add_event(@event_factory_default.new_custom_event(event_name, user, data, metric_value)) end # # Returns all feature flag values for the given user. # # @deprecated Please use {#all_flags_state} instead. Current versions of the # client-side SDK will not generate analytics events correctly if you pass the # result of `all_flags`. # # @param user [Hash] The end user requesting the feature flags # @return [Hash] a hash of feature flag keys to values # def all_flags(user) all_flags_state(user).values_map end # # Returns a {FeatureFlagsState} object that encapsulates the state of all feature flags for a given user, # including the flag values and also metadata that can be used on the front end. This method does not # send analytics events back to LaunchDarkly. # # @param user [Hash] The end user requesting the feature flags # @param options [Hash] Optional parameters to control how the state is generated # @option options [Boolean] :client_side_only (false) True if only flags marked for use with the # client-side SDK should be included in the state. By default, all flags are included. # @option options [Boolean] :with_reasons (false) True if evaluation reasons should be included # in the state (see {#variation_detail}). By default, they are not included. # @option options [Boolean] :details_only_for_tracked_flags (false) True if any flag metadata that is # normally only used for event generation - such as flag versions and evaluation reasons - should be # omitted for any flag that does not have event tracking or debugging turned on. This reduces the size # of the JSON data if you are passing the flag state to the front end. # @return [FeatureFlagsState] a {FeatureFlagsState} object which can be serialized to JSON # def all_flags_state(user, options={}) return FeatureFlagsState.new(false) if @config.offline? unless user && !user[:key].nil? @config.logger.error { "[LDClient] User and user key must be specified in all_flags_state" } return FeatureFlagsState.new(false) end begin features = @store.all(FEATURES) rescue => exn Util.log_exception(@config.logger, "Unable to read flags for all_flags_state", exn) return FeatureFlagsState.new(false) end state = FeatureFlagsState.new(true) client_only = options[:client_side_only] || false with_reasons = options[:with_reasons] || false details_only_if_tracked = options[:details_only_for_tracked_flags] || false features.each do |k, f| if client_only && !f[:clientSide] next end begin result = evaluate(f, user, @store, @config.logger, @event_factory_default) state.add_flag(f, result.detail.value, result.detail.variation_index, with_reasons ? result.detail.reason : nil, details_only_if_tracked) rescue => exn Util.log_exception(@config.logger, "Error evaluating flag \"#{k}\" in all_flags_state", exn) state.add_flag(f, nil, nil, with_reasons ? { kind: 'ERROR', errorKind: 'EXCEPTION' } : nil, details_only_if_tracked) end end state end # # Releases all network connections and other resources held by the client, making it no longer usable. # # @return [void] def close @config.logger.info { "[LDClient] Closing LaunchDarkly client..." } @data_source.stop @event_processor.stop @store.stop end private def create_default_data_source(sdk_key, config, diagnostic_accumulator) if config.offline? return NullUpdateProcessor.new end raise ArgumentError, "sdk_key must not be nil" if sdk_key.nil? # see LDClient constructor comment on sdk_key if config.stream? StreamProcessor.new(sdk_key, config, diagnostic_accumulator) else config.logger.info { "Disabling streaming API" } config.logger.warn { "You should only disable the streaming API if instructed to do so by LaunchDarkly support" } requestor = Requestor.new(sdk_key, config) PollingProcessor.new(config, requestor) end end # @return [EvaluationDetail] def evaluate_internal(key, user, default, event_factory) if @config.offline? return error_result('CLIENT_NOT_READY', default) end if !initialized? if @store.initialized? @config.logger.warn { "[LDClient] Client has not finished initializing; using last known values from feature store" } else @config.logger.error { "[LDClient] Client has not finished initializing; feature store unavailable, returning default value" } detail = error_result('CLIENT_NOT_READY', default) @event_processor.add_event(event_factory.new_unknown_flag_event(key, user, default, detail.reason)) return detail end end feature = @store.get(FEATURES, key) if feature.nil? @config.logger.info { "[LDClient] Unknown feature flag \"#{key}\". Returning default value" } detail = error_result('FLAG_NOT_FOUND', default) @event_processor.add_event(event_factory.new_unknown_flag_event(key, user, default, detail.reason)) return detail end unless user @config.logger.error { "[LDClient] Must specify user" } detail = error_result('USER_NOT_SPECIFIED', default) @event_processor.add_event(event_factory.new_default_event(feature, user, default, detail.reason)) return detail end begin res = evaluate(feature, user, @store, @config.logger, event_factory) if !res.events.nil? res.events.each do |event| @event_processor.add_event(event) end end detail = res.detail if detail.default_value? detail = EvaluationDetail.new(default, nil, detail.reason) end @event_processor.add_event(event_factory.new_eval_event(feature, user, detail, default)) return detail rescue => exn Util.log_exception(@config.logger, "Error evaluating feature flag \"#{key}\"", exn) detail = error_result('EXCEPTION', default) @event_processor.add_event(event_factory.new_default_event(feature, user, default, detail.reason)) return detail end end def sanitize_user(user) if user[:key] user[:key] = user[:key].to_s end end end # # Used internally when the client is offline. # @private # class NullUpdateProcessor def start e = Concurrent::Event.new e.set e end def initialized? true end def stop end end end