ldclient-rb 5.4.3 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +33 -6
  3. data/CHANGELOG.md +19 -0
  4. data/CONTRIBUTING.md +0 -12
  5. data/Gemfile.lock +22 -3
  6. data/README.md +41 -35
  7. data/ldclient-rb.gemspec +4 -3
  8. data/lib/ldclient-rb.rb +9 -1
  9. data/lib/ldclient-rb/cache_store.rb +1 -0
  10. data/lib/ldclient-rb/config.rb +201 -90
  11. data/lib/ldclient-rb/evaluation.rb +56 -8
  12. data/lib/ldclient-rb/event_summarizer.rb +3 -0
  13. data/lib/ldclient-rb/events.rb +16 -0
  14. data/lib/ldclient-rb/expiring_cache.rb +1 -0
  15. data/lib/ldclient-rb/file_data_source.rb +18 -13
  16. data/lib/ldclient-rb/flags_state.rb +3 -2
  17. data/lib/ldclient-rb/impl.rb +13 -0
  18. data/lib/ldclient-rb/impl/integrations/consul_impl.rb +158 -0
  19. data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +228 -0
  20. data/lib/ldclient-rb/impl/integrations/redis_impl.rb +155 -0
  21. data/lib/ldclient-rb/impl/store_client_wrapper.rb +47 -0
  22. data/lib/ldclient-rb/impl/store_data_set_sorter.rb +55 -0
  23. data/lib/ldclient-rb/in_memory_store.rb +15 -4
  24. data/lib/ldclient-rb/integrations.rb +55 -0
  25. data/lib/ldclient-rb/integrations/consul.rb +38 -0
  26. data/lib/ldclient-rb/integrations/dynamodb.rb +47 -0
  27. data/lib/ldclient-rb/integrations/redis.rb +55 -0
  28. data/lib/ldclient-rb/integrations/util/store_wrapper.rb +230 -0
  29. data/lib/ldclient-rb/interfaces.rb +153 -0
  30. data/lib/ldclient-rb/ldclient.rb +135 -77
  31. data/lib/ldclient-rb/memoized_value.rb +2 -0
  32. data/lib/ldclient-rb/newrelic.rb +1 -0
  33. data/lib/ldclient-rb/non_blocking_thread_pool.rb +3 -3
  34. data/lib/ldclient-rb/polling.rb +1 -0
  35. data/lib/ldclient-rb/redis_store.rb +24 -190
  36. data/lib/ldclient-rb/requestor.rb +3 -2
  37. data/lib/ldclient-rb/simple_lru_cache.rb +1 -0
  38. data/lib/ldclient-rb/stream.rb +22 -10
  39. data/lib/ldclient-rb/user_filter.rb +1 -0
  40. data/lib/ldclient-rb/util.rb +1 -0
  41. data/lib/ldclient-rb/version.rb +1 -1
  42. data/scripts/gendocs.sh +12 -0
  43. data/spec/feature_store_spec_base.rb +173 -72
  44. data/spec/file_data_source_spec.rb +2 -2
  45. data/spec/http_util.rb +103 -0
  46. data/spec/in_memory_feature_store_spec.rb +1 -1
  47. data/spec/integrations/consul_feature_store_spec.rb +41 -0
  48. data/spec/integrations/dynamodb_feature_store_spec.rb +104 -0
  49. data/spec/integrations/store_wrapper_spec.rb +276 -0
  50. data/spec/ldclient_spec.rb +83 -4
  51. data/spec/redis_feature_store_spec.rb +25 -16
  52. data/spec/requestor_spec.rb +44 -38
  53. data/spec/stream_spec.rb +18 -18
  54. metadata +55 -33
  55. data/lib/sse_client.rb +0 -4
  56. data/lib/sse_client/backoff.rb +0 -38
  57. data/lib/sse_client/sse_client.rb +0 -171
  58. data/lib/sse_client/sse_events.rb +0 -67
  59. data/lib/sse_client/streaming_http.rb +0 -199
  60. data/spec/sse_client/sse_client_spec.rb +0 -177
  61. data/spec/sse_client/sse_events_spec.rb +0 -100
  62. data/spec/sse_client/sse_shared.rb +0 -82
  63. data/spec/sse_client/streaming_http_spec.rb +0 -263
@@ -2,7 +2,7 @@ require "date"
2
2
  require "semantic"
3
3
 
4
4
  module LaunchDarkly
5
- # An object returned by `LDClient.variation_detail`, combining the result of a flag evaluation with
5
+ # An object returned by {LDClient#variation_detail}, combining the result of a flag evaluation with
6
6
  # an explanation of how it was calculated.
7
7
  class EvaluationDetail
8
8
  def initialize(value, variation_index, reason)
@@ -11,19 +11,66 @@ module LaunchDarkly
11
11
  @reason = reason
12
12
  end
13
13
 
14
- # @return [Object] The result of the flag evaluation. This will be either one of the flag's
15
- # variations or the default value that was passed to the `variation` method.
14
+ #
15
+ # The result of the flag evaluation. This will be either one of the flag's variations, or the
16
+ # default value that was passed to {LDClient#variation_detail}. It is the same as the return
17
+ # value of {LDClient#variation}.
18
+ #
19
+ # @return [Object]
20
+ #
16
21
  attr_reader :value
17
22
 
18
- # @return [int|nil] The index of the returned value within the flag's list of variations, e.g.
19
- # 0 for the first variation - or `nil` if the default value was returned.
23
+ #
24
+ # The index of the returned value within the flag's list of variations. The first variation is
25
+ # 0, the second is 1, etc. This is `nil` if the default value was returned.
26
+ #
27
+ # @return [int|nil]
28
+ #
20
29
  attr_reader :variation_index
21
30
 
22
- # @return [Hash] An object describing the main factor that influenced the flag evaluation value.
31
+ #
32
+ # An object describing the main factor that influenced the flag evaluation value.
33
+ #
34
+ # This object is currently represented as a Hash, which may have the following keys:
35
+ #
36
+ # `:kind`: The general category of reason. Possible values:
37
+ #
38
+ # * `'OFF'`: the flag was off and therefore returned its configured off value
39
+ # * `'FALLTHROUGH'`: the flag was on but the user did not match any targets or rules
40
+ # * `'TARGET_MATCH'`: the user key was specifically targeted for this flag
41
+ # * `'RULE_MATCH'`: the user matched one of the flag's rules
42
+ # * `'PREREQUISITE_FAILED`': the flag was considered off because it had at least one
43
+ # prerequisite flag that either was off or did not return the desired variation
44
+ # * `'ERROR'`: the flag could not be evaluated, so the default value was returned
45
+ #
46
+ # `:ruleIndex`: If the kind was `RULE_MATCH`, this is the positional index of the
47
+ # matched rule (0 for the first rule).
48
+ #
49
+ # `:ruleId`: If the kind was `RULE_MATCH`, this is the rule's unique identifier.
50
+ #
51
+ # `:prerequisiteKey`: If the kind was `PREREQUISITE_FAILED`, this is the flag key of
52
+ # the prerequisite flag that failed.
53
+ #
54
+ # `:errorKind`: If the kind was `ERROR`, this indicates the type of error:
55
+ #
56
+ # * `'CLIENT_NOT_READY'`: the caller tried to evaluate a flag before the client had
57
+ # successfully initialized
58
+ # * `'FLAG_NOT_FOUND'`: the caller provided a flag key that did not match any known flag
59
+ # * `'MALFORMED_FLAG'`: there was an internal inconsistency in the flag data, e.g. a
60
+ # rule specified a nonexistent variation
61
+ # * `'USER_NOT_SPECIFIED'`: the user object or user key was not provied
62
+ # * `'EXCEPTION'`: an unexpected exception stopped flag evaluation
63
+ #
64
+ # @return [Hash]
65
+ #
23
66
  attr_reader :reason
24
67
 
25
- # @return [boolean] True if the flag evaluated to the default value rather than to one of its
26
- # variations.
68
+ #
69
+ # Tests whether the flag evaluation returned a default value. This is the same as checking
70
+ # whether {#variation_index} is nil.
71
+ #
72
+ # @return [Boolean]
73
+ #
27
74
  def default_value?
28
75
  variation_index.nil?
29
76
  end
@@ -33,6 +80,7 @@ module LaunchDarkly
33
80
  end
34
81
  end
35
82
 
83
+ # @private
36
84
  module Evaluation
37
85
  BUILTINS = [:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous]
38
86
 
@@ -1,11 +1,14 @@
1
1
 
2
2
  module LaunchDarkly
3
+ # @private
3
4
  EventSummary = Struct.new(:start_date, :end_date, :counters)
4
5
 
5
6
  # Manages the state of summarizable information for the EventProcessor, including the
6
7
  # event counters and user deduplication. Note that the methods of this class are
7
8
  # deliberately not thread-safe; the EventProcessor is responsible for enforcing
8
9
  # synchronization across both the summarizer and the event queue.
10
+ #
11
+ # @private
9
12
  class EventSummarizer
10
13
  def initialize
11
14
  clear
@@ -9,6 +9,10 @@ module LaunchDarkly
9
9
  MAX_FLUSH_WORKERS = 5
10
10
  CURRENT_SCHEMA_VERSION = 3
11
11
 
12
+ private_constant :MAX_FLUSH_WORKERS
13
+ private_constant :CURRENT_SCHEMA_VERSION
14
+
15
+ # @private
12
16
  class NullEventProcessor
13
17
  def add_event(event)
14
18
  end
@@ -20,6 +24,7 @@ module LaunchDarkly
20
24
  end
21
25
  end
22
26
 
27
+ # @private
23
28
  class EventMessage
24
29
  def initialize(event)
25
30
  @event = event
@@ -27,12 +32,15 @@ module LaunchDarkly
27
32
  attr_reader :event
28
33
  end
29
34
 
35
+ # @private
30
36
  class FlushMessage
31
37
  end
32
38
 
39
+ # @private
33
40
  class FlushUsersMessage
34
41
  end
35
42
 
43
+ # @private
36
44
  class SynchronousMessage
37
45
  def initialize
38
46
  @reply = Concurrent::Semaphore.new(0)
@@ -47,12 +55,15 @@ module LaunchDarkly
47
55
  end
48
56
  end
49
57
 
58
+ # @private
50
59
  class TestSyncMessage < SynchronousMessage
51
60
  end
52
61
 
62
+ # @private
53
63
  class StopMessage < SynchronousMessage
54
64
  end
55
65
 
66
+ # @private
56
67
  class EventProcessor
57
68
  def initialize(sdk_key, config, client = nil)
58
69
  @queue = Queue.new
@@ -99,6 +110,7 @@ module LaunchDarkly
99
110
  end
100
111
  end
101
112
 
113
+ # @private
102
114
  class EventDispatcher
103
115
  def initialize(queue, sdk_key, config, client)
104
116
  @sdk_key = sdk_key
@@ -252,8 +264,10 @@ module LaunchDarkly
252
264
  end
253
265
  end
254
266
 
267
+ # @private
255
268
  FlushPayload = Struct.new(:events, :summary)
256
269
 
270
+ # @private
257
271
  class EventBuffer
258
272
  def initialize(capacity, logger)
259
273
  @capacity = capacity
@@ -290,6 +304,7 @@ module LaunchDarkly
290
304
  end
291
305
  end
292
306
 
307
+ # @private
293
308
  class EventPayloadSendTask
294
309
  def run(sdk_key, config, client, payload, formatter)
295
310
  events_out = formatter.make_output_events(payload.events, payload.summary)
@@ -327,6 +342,7 @@ module LaunchDarkly
327
342
  end
328
343
  end
329
344
 
345
+ # @private
330
346
  class EventOutputFormatter
331
347
  def initialize(config)
332
348
  @inline_users = config.inline_users_in_events
@@ -6,6 +6,7 @@ module LaunchDarkly
6
6
  # * made thread-safe
7
7
  # * removed many unused methods
8
8
  # * reading a key does not reset its expiration time, only writing
9
+ # @private
9
10
  class ExpiringCache
10
11
  def initialize(max_size, ttl)
11
12
  @max_size = max_size
@@ -7,12 +7,15 @@ module LaunchDarkly
7
7
  # To avoid pulling in 'listen' and its transitive dependencies for people who aren't using the
8
8
  # file data source or who don't need auto-updating, we only enable auto-update if the 'listen'
9
9
  # gem has been provided by the host app.
10
+ # @private
10
11
  @@have_listen = false
11
12
  begin
12
13
  require 'listen'
13
14
  @@have_listen = true
14
15
  rescue LoadError
15
16
  end
17
+
18
+ # @private
16
19
  def self.have_listen?
17
20
  @@have_listen
18
21
  end
@@ -22,30 +25,32 @@ module LaunchDarkly
22
25
  # used in a test environment, to operate using a predetermined feature flag state without an
23
26
  # actual LaunchDarkly connection.
24
27
  #
25
- # To use this component, call `FileDataSource.factory`, and store its return value in the
26
- # `update_processor_factory` property of your LaunchDarkly client configuration. In the options
28
+ # To use this component, call {FileDataSource#factory}, and store its return value in the
29
+ # {Config#data_source} property of your LaunchDarkly client configuration. In the options
27
30
  # to `factory`, set `paths` to the file path(s) of your data file(s):
28
31
  #
29
- # factory = FileDataSource.factory(paths: [ myFilePath ])
30
- # config = LaunchDarkly::Config.new(update_processor_factory: factory)
32
+ # file_source = FileDataSource.factory(paths: [ myFilePath ])
33
+ # config = LaunchDarkly::Config.new(data_source: file_source)
31
34
  #
32
35
  # This will cause the client not to connect to LaunchDarkly to get feature flags. The
33
36
  # client may still make network connections to send analytics events, unless you have disabled
34
- # this with Config.send_events or Config.offline.
37
+ # this with {Config#send_events} or {Config#offline?}.
35
38
  #
36
39
  # Flag data files can be either JSON or YAML. They contain an object with three possible
37
40
  # properties:
38
41
  #
39
- # - "flags": Feature flag definitions.
40
- # - "flagValues": Simplified feature flags that contain only a value.
41
- # - "segments": User segment definitions.
42
+ # - `flags`: Feature flag definitions.
43
+ # - `flagValues`: Simplified feature flags that contain only a value.
44
+ # - `segments`: User segment definitions.
42
45
  #
43
- # The format of the data in "flags" and "segments" is defined by the LaunchDarkly application
46
+ # The format of the data in `flags` and `segments` is defined by the LaunchDarkly application
44
47
  # and is subject to change. Rather than trying to construct these objects yourself, it is simpler
45
48
  # to request existing flags directly from the LaunchDarkly server in JSON format, and use this
46
49
  # output as the starting point for your file. In Linux you would do this:
47
50
  #
48
- # curl -H "Authorization: {your sdk key}" https://app.launchdarkly.com/sdk/latest-all
51
+ # ```
52
+ # curl -H "Authorization: YOUR_SDK_KEY" https://app.launchdarkly.com/sdk/latest-all
53
+ # ```
49
54
  #
50
55
  # The output will look something like this (but with many more properties):
51
56
  #
@@ -108,14 +113,14 @@ module LaunchDarkly
108
113
  # @option options [Float] :poll_interval The minimum interval, in seconds, between checks for
109
114
  # file modifications - used only if auto_update is true, and if the native file-watching
110
115
  # mechanism from 'listen' is not being used. The default value is 1 second.
116
+ # @return an object that can be stored in {Config#data_source}
111
117
  #
112
118
  def self.factory(options={})
113
- return Proc.new do |sdk_key, config|
114
- FileDataSourceImpl.new(config.feature_store, config.logger, options)
115
- end
119
+ return lambda { |sdk_key, config| FileDataSourceImpl.new(config.feature_store, config.logger, options) }
116
120
  end
117
121
  end
118
122
 
123
+ # @private
119
124
  class FileDataSourceImpl
120
125
  def initialize(feature_store, logger, options={})
121
126
  @feature_store = feature_store
@@ -3,8 +3,8 @@ require 'json'
3
3
  module LaunchDarkly
4
4
  #
5
5
  # A snapshot of the state of all feature flags with regard to a specific user, generated by
6
- # calling the client's all_flags_state method. Serializing this object to JSON using
7
- # JSON.generate (or the to_json method) will produce the appropriate data structure for
6
+ # calling the {LDClient#all_flags_state}. Serializing this object to JSON using
7
+ # `JSON.generate` (or the `to_json` method) will produce the appropriate data structure for
8
8
  # bootstrapping the LaunchDarkly JavaScript client.
9
9
  #
10
10
  class FeatureFlagsState
@@ -15,6 +15,7 @@ module LaunchDarkly
15
15
  end
16
16
 
17
17
  # Used internally to build the state map.
18
+ # @private
18
19
  def add_flag(flag, value, variation, reason = nil, details_only_if_tracked = false)
19
20
  key = flag[:key]
20
21
  @flag_values[key] = value
@@ -0,0 +1,13 @@
1
+
2
+ module LaunchDarkly
3
+ #
4
+ # Internal implementation classes. Everything in this module should be considered unsupported
5
+ # and subject to change.
6
+ #
7
+ # @since 5.5.0
8
+ # @private
9
+ #
10
+ module Impl
11
+ # code is in ldclient-rb/impl/
12
+ end
13
+ end
@@ -0,0 +1,158 @@
1
+ require "json"
2
+
3
+ module LaunchDarkly
4
+ module Impl
5
+ module Integrations
6
+ module Consul
7
+ #
8
+ # Internal implementation of the Consul feature store, intended to be used with CachingStoreWrapper.
9
+ #
10
+ class ConsulFeatureStoreCore
11
+ begin
12
+ require "diplomat"
13
+ CONSUL_ENABLED = true
14
+ rescue ScriptError, StandardError
15
+ CONSUL_ENABLED = false
16
+ end
17
+
18
+ def initialize(opts)
19
+ if !CONSUL_ENABLED
20
+ raise RuntimeError.new("can't use Consul feature store without the 'diplomat' gem")
21
+ end
22
+
23
+ @prefix = (opts[:prefix] || LaunchDarkly::Integrations::Consul.default_prefix) + '/'
24
+ @logger = opts[:logger] || Config.default_logger
25
+ Diplomat.configuration = opts[:consul_config] if !opts[:consul_config].nil?
26
+ Diplomat.configuration.url = opts[:url] if !opts[:url].nil?
27
+ @logger.info("ConsulFeatureStore: using Consul host at #{Diplomat.configuration.url}")
28
+ end
29
+
30
+ def init_internal(all_data)
31
+ # Start by reading the existing keys; we will later delete any of these that weren't in all_data.
32
+ unused_old_keys = Set.new
33
+ keys = Diplomat::Kv.get(@prefix, { keys: true, recurse: true }, :return)
34
+ unused_old_keys.merge(keys) if keys != ""
35
+
36
+ ops = []
37
+ num_items = 0
38
+
39
+ # Insert or update every provided item
40
+ all_data.each do |kind, items|
41
+ items.values.each do |item|
42
+ value = item.to_json
43
+ key = item_key(kind, item[:key])
44
+ ops.push({ 'KV' => { 'Verb' => 'set', 'Key' => key, 'Value' => value } })
45
+ unused_old_keys.delete(key)
46
+ num_items = num_items + 1
47
+ end
48
+ end
49
+
50
+ # Now delete any previously existing items whose keys were not in the current data
51
+ unused_old_keys.each do |key|
52
+ ops.push({ 'KV' => { 'Verb' => 'delete', 'Key' => key } })
53
+ end
54
+
55
+ # Now set the special key that we check in initialized_internal?
56
+ ops.push({ 'KV' => { 'Verb' => 'set', 'Key' => inited_key, 'Value' => '' } })
57
+
58
+ ConsulUtil.batch_operations(ops)
59
+
60
+ @logger.info { "Initialized database with #{num_items} items" }
61
+ end
62
+
63
+ def get_internal(kind, key)
64
+ value = Diplomat::Kv.get(item_key(kind, key), {}, :return) # :return means "don't throw an error if not found"
65
+ (value.nil? || value == "") ? nil : JSON.parse(value, symbolize_names: true)
66
+ end
67
+
68
+ def get_all_internal(kind)
69
+ items_out = {}
70
+ results = Diplomat::Kv.get(kind_key(kind), { recurse: true }, :return)
71
+ (results == "" ? [] : results).each do |result|
72
+ value = result[:value]
73
+ if !value.nil?
74
+ item = JSON.parse(value, symbolize_names: true)
75
+ items_out[item[:key].to_sym] = item
76
+ end
77
+ end
78
+ items_out
79
+ end
80
+
81
+ def upsert_internal(kind, new_item)
82
+ key = item_key(kind, new_item[:key])
83
+ json = new_item.to_json
84
+
85
+ # We will potentially keep retrying indefinitely until someone's write succeeds
86
+ while true
87
+ old_value = Diplomat::Kv.get(key, { decode_values: true }, :return)
88
+ if old_value.nil? || old_value == ""
89
+ mod_index = 0
90
+ else
91
+ old_item = JSON.parse(old_value[0]["Value"], symbolize_names: true)
92
+ # Check whether the item is stale. If so, don't do the update (and return the existing item to
93
+ # FeatureStoreWrapper so it can be cached)
94
+ if old_item[:version] >= new_item[:version]
95
+ return old_item
96
+ end
97
+ mod_index = old_value[0]["ModifyIndex"]
98
+ end
99
+
100
+ # Otherwise, try to write. We will do a compare-and-set operation, so the write will only succeed if
101
+ # the key's ModifyIndex is still equal to the previous value. If the previous ModifyIndex was zero,
102
+ # it means the key did not previously exist and the write will only succeed if it still doesn't exist.
103
+ success = Diplomat::Kv.put(key, json, cas: mod_index)
104
+ return new_item if success
105
+
106
+ # If we failed, retry the whole shebang
107
+ @logger.debug { "Concurrent modification detected, retrying" }
108
+ end
109
+ end
110
+
111
+ def initialized_internal?
112
+ # Unfortunately we need to use exceptions here, instead of the :return parameter, because with
113
+ # :return there's no way to distinguish between a missing value and an empty string.
114
+ begin
115
+ Diplomat::Kv.get(inited_key, {})
116
+ true
117
+ rescue Diplomat::KeyNotFound
118
+ false
119
+ end
120
+ end
121
+
122
+ def stop
123
+ # There's no Consul client instance to dispose of
124
+ end
125
+
126
+ private
127
+
128
+ def item_key(kind, key)
129
+ kind_key(kind) + key.to_s
130
+ end
131
+
132
+ def kind_key(kind)
133
+ @prefix + kind[:namespace] + '/'
134
+ end
135
+
136
+ def inited_key
137
+ @prefix + '$inited'
138
+ end
139
+ end
140
+
141
+ class ConsulUtil
142
+ #
143
+ # Submits as many transactions as necessary to submit all of the given operations.
144
+ # The ops array is consumed.
145
+ #
146
+ def self.batch_operations(ops)
147
+ batch_size = 64 # Consul can only do this many at a time
148
+ while true
149
+ chunk = ops.shift(batch_size)
150
+ break if chunk.empty?
151
+ Diplomat::Kv.txn(chunk)
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end