ldclient-rb 5.4.3 → 5.5.0

Sign up to get free protection for your applications and to get access to all the features.
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