launchdarkly-server-sdk 5.5.7

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 (87) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +134 -0
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +37 -0
  4. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  5. data/.gitignore +15 -0
  6. data/.hound.yml +2 -0
  7. data/.rspec +2 -0
  8. data/.rubocop.yml +600 -0
  9. data/.simplecov +4 -0
  10. data/.yardopts +9 -0
  11. data/CHANGELOG.md +261 -0
  12. data/CODEOWNERS +1 -0
  13. data/CONTRIBUTING.md +37 -0
  14. data/Gemfile +3 -0
  15. data/Gemfile.lock +102 -0
  16. data/LICENSE.txt +13 -0
  17. data/README.md +56 -0
  18. data/Rakefile +5 -0
  19. data/azure-pipelines.yml +51 -0
  20. data/ext/mkrf_conf.rb +11 -0
  21. data/launchdarkly-server-sdk.gemspec +40 -0
  22. data/lib/ldclient-rb.rb +29 -0
  23. data/lib/ldclient-rb/cache_store.rb +45 -0
  24. data/lib/ldclient-rb/config.rb +411 -0
  25. data/lib/ldclient-rb/evaluation.rb +455 -0
  26. data/lib/ldclient-rb/event_summarizer.rb +55 -0
  27. data/lib/ldclient-rb/events.rb +468 -0
  28. data/lib/ldclient-rb/expiring_cache.rb +77 -0
  29. data/lib/ldclient-rb/file_data_source.rb +312 -0
  30. data/lib/ldclient-rb/flags_state.rb +76 -0
  31. data/lib/ldclient-rb/impl.rb +13 -0
  32. data/lib/ldclient-rb/impl/integrations/consul_impl.rb +158 -0
  33. data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +228 -0
  34. data/lib/ldclient-rb/impl/integrations/redis_impl.rb +155 -0
  35. data/lib/ldclient-rb/impl/store_client_wrapper.rb +47 -0
  36. data/lib/ldclient-rb/impl/store_data_set_sorter.rb +55 -0
  37. data/lib/ldclient-rb/in_memory_store.rb +100 -0
  38. data/lib/ldclient-rb/integrations.rb +55 -0
  39. data/lib/ldclient-rb/integrations/consul.rb +38 -0
  40. data/lib/ldclient-rb/integrations/dynamodb.rb +47 -0
  41. data/lib/ldclient-rb/integrations/redis.rb +55 -0
  42. data/lib/ldclient-rb/integrations/util/store_wrapper.rb +230 -0
  43. data/lib/ldclient-rb/interfaces.rb +153 -0
  44. data/lib/ldclient-rb/ldclient.rb +424 -0
  45. data/lib/ldclient-rb/memoized_value.rb +32 -0
  46. data/lib/ldclient-rb/newrelic.rb +17 -0
  47. data/lib/ldclient-rb/non_blocking_thread_pool.rb +46 -0
  48. data/lib/ldclient-rb/polling.rb +78 -0
  49. data/lib/ldclient-rb/redis_store.rb +87 -0
  50. data/lib/ldclient-rb/requestor.rb +101 -0
  51. data/lib/ldclient-rb/simple_lru_cache.rb +25 -0
  52. data/lib/ldclient-rb/stream.rb +141 -0
  53. data/lib/ldclient-rb/user_filter.rb +51 -0
  54. data/lib/ldclient-rb/util.rb +50 -0
  55. data/lib/ldclient-rb/version.rb +3 -0
  56. data/scripts/gendocs.sh +11 -0
  57. data/scripts/release.sh +27 -0
  58. data/spec/config_spec.rb +63 -0
  59. data/spec/evaluation_spec.rb +739 -0
  60. data/spec/event_summarizer_spec.rb +63 -0
  61. data/spec/events_spec.rb +642 -0
  62. data/spec/expiring_cache_spec.rb +76 -0
  63. data/spec/feature_store_spec_base.rb +213 -0
  64. data/spec/file_data_source_spec.rb +255 -0
  65. data/spec/fixtures/feature.json +37 -0
  66. data/spec/fixtures/feature1.json +36 -0
  67. data/spec/fixtures/user.json +9 -0
  68. data/spec/flags_state_spec.rb +81 -0
  69. data/spec/http_util.rb +109 -0
  70. data/spec/in_memory_feature_store_spec.rb +12 -0
  71. data/spec/integrations/consul_feature_store_spec.rb +42 -0
  72. data/spec/integrations/dynamodb_feature_store_spec.rb +105 -0
  73. data/spec/integrations/store_wrapper_spec.rb +276 -0
  74. data/spec/ldclient_spec.rb +471 -0
  75. data/spec/newrelic_spec.rb +5 -0
  76. data/spec/polling_spec.rb +120 -0
  77. data/spec/redis_feature_store_spec.rb +95 -0
  78. data/spec/requestor_spec.rb +214 -0
  79. data/spec/segment_store_spec_base.rb +95 -0
  80. data/spec/simple_lru_cache_spec.rb +24 -0
  81. data/spec/spec_helper.rb +9 -0
  82. data/spec/store_spec.rb +10 -0
  83. data/spec/stream_spec.rb +60 -0
  84. data/spec/user_filter_spec.rb +91 -0
  85. data/spec/util_spec.rb +17 -0
  86. data/spec/version_spec.rb +7 -0
  87. metadata +375 -0
@@ -0,0 +1,77 @@
1
+
2
+ module LaunchDarkly
3
+ # A thread-safe cache with maximum number of entries and TTL.
4
+ # Adapted from https://github.com/SamSaffron/lru_redux/blob/master/lib/lru_redux/ttl/cache.rb
5
+ # under MIT license with the following changes:
6
+ # * made thread-safe
7
+ # * removed many unused methods
8
+ # * reading a key does not reset its expiration time, only writing
9
+ # @private
10
+ class ExpiringCache
11
+ def initialize(max_size, ttl)
12
+ @max_size = max_size
13
+ @ttl = ttl
14
+ @data_lru = {}
15
+ @data_ttl = {}
16
+ @lock = Mutex.new
17
+ end
18
+
19
+ def [](key)
20
+ @lock.synchronize do
21
+ ttl_evict
22
+ @data_lru[key]
23
+ end
24
+ end
25
+
26
+ def []=(key, val)
27
+ @lock.synchronize do
28
+ ttl_evict
29
+
30
+ @data_lru.delete(key)
31
+ @data_ttl.delete(key)
32
+
33
+ @data_lru[key] = val
34
+ @data_ttl[key] = Time.now.to_f
35
+
36
+ if @data_lru.size > @max_size
37
+ key, _ = @data_lru.first # hashes have a FIFO ordering in Ruby
38
+
39
+ @data_ttl.delete(key)
40
+ @data_lru.delete(key)
41
+ end
42
+
43
+ val
44
+ end
45
+ end
46
+
47
+ def delete(key)
48
+ @lock.synchronize do
49
+ ttl_evict
50
+
51
+ @data_lru.delete(key)
52
+ @data_ttl.delete(key)
53
+ end
54
+ end
55
+
56
+ def clear
57
+ @lock.synchronize do
58
+ @data_lru.clear
59
+ @data_ttl.clear
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def ttl_evict
66
+ ttl_horizon = Time.now.to_f - @ttl
67
+ key, time = @data_ttl.first
68
+
69
+ until time.nil? || time > ttl_horizon
70
+ @data_ttl.delete(key)
71
+ @data_lru.delete(key)
72
+
73
+ key, time = @data_ttl.first
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,312 @@
1
+ require 'concurrent/atomics'
2
+ require 'json'
3
+ require 'yaml'
4
+ require 'pathname'
5
+
6
+ module LaunchDarkly
7
+ # To avoid pulling in 'listen' and its transitive dependencies for people who aren't using the
8
+ # file data source or who don't need auto-updating, we only enable auto-update if the 'listen'
9
+ # gem has been provided by the host app.
10
+ # @private
11
+ @@have_listen = false
12
+ begin
13
+ require 'listen'
14
+ @@have_listen = true
15
+ rescue LoadError
16
+ end
17
+
18
+ # @private
19
+ def self.have_listen?
20
+ @@have_listen
21
+ end
22
+
23
+ #
24
+ # Provides a way to use local files as a source of feature flag state. This would typically be
25
+ # used in a test environment, to operate using a predetermined feature flag state without an
26
+ # actual LaunchDarkly connection.
27
+ #
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
30
+ # to `factory`, set `paths` to the file path(s) of your data file(s):
31
+ #
32
+ # file_source = FileDataSource.factory(paths: [ myFilePath ])
33
+ # config = LaunchDarkly::Config.new(data_source: file_source)
34
+ #
35
+ # This will cause the client not to connect to LaunchDarkly to get feature flags. The
36
+ # client may still make network connections to send analytics events, unless you have disabled
37
+ # this with {Config#send_events} or {Config#offline?}.
38
+ #
39
+ # Flag data files can be either JSON or YAML. They contain an object with three possible
40
+ # properties:
41
+ #
42
+ # - `flags`: Feature flag definitions.
43
+ # - `flagValues`: Simplified feature flags that contain only a value.
44
+ # - `segments`: User segment definitions.
45
+ #
46
+ # The format of the data in `flags` and `segments` is defined by the LaunchDarkly application
47
+ # and is subject to change. Rather than trying to construct these objects yourself, it is simpler
48
+ # to request existing flags directly from the LaunchDarkly server in JSON format, and use this
49
+ # output as the starting point for your file. In Linux you would do this:
50
+ #
51
+ # ```
52
+ # curl -H "Authorization: YOUR_SDK_KEY" https://app.launchdarkly.com/sdk/latest-all
53
+ # ```
54
+ #
55
+ # The output will look something like this (but with many more properties):
56
+ #
57
+ # {
58
+ # "flags": {
59
+ # "flag-key-1": {
60
+ # "key": "flag-key-1",
61
+ # "on": true,
62
+ # "variations": [ "a", "b" ]
63
+ # }
64
+ # },
65
+ # "segments": {
66
+ # "segment-key-1": {
67
+ # "key": "segment-key-1",
68
+ # "includes": [ "user-key-1" ]
69
+ # }
70
+ # }
71
+ # }
72
+ #
73
+ # Data in this format allows the SDK to exactly duplicate all the kinds of flag behavior supported
74
+ # by LaunchDarkly. However, in many cases you will not need this complexity, but will just want to
75
+ # set specific flag keys to specific values. For that, you can use a much simpler format:
76
+ #
77
+ # {
78
+ # "flagValues": {
79
+ # "my-string-flag-key": "value-1",
80
+ # "my-boolean-flag-key": true,
81
+ # "my-integer-flag-key": 3
82
+ # }
83
+ # }
84
+ #
85
+ # Or, in YAML:
86
+ #
87
+ # flagValues:
88
+ # my-string-flag-key: "value-1"
89
+ # my-boolean-flag-key: true
90
+ # my-integer-flag-key: 1
91
+ #
92
+ # It is also possible to specify both "flags" and "flagValues", if you want some flags
93
+ # to have simple values and others to have complex behavior. However, it is an error to use the
94
+ # same flag key or segment key more than once, either in a single file or across multiple files.
95
+ #
96
+ # If the data source encounters any error in any file-- malformed content, a missing file, or a
97
+ # duplicate key-- it will not load flags from any of the files.
98
+ #
99
+ class FileDataSource
100
+ #
101
+ # Returns a factory for the file data source component.
102
+ #
103
+ # @param options [Hash] the configuration options
104
+ # @option options [Array] :paths The paths of the source files for loading flag data. These
105
+ # may be absolute paths or relative to the current working directory.
106
+ # @option options [Boolean] :auto_update True if the data source should watch for changes to
107
+ # the source file(s) and reload flags whenever there is a change. Auto-updating will only
108
+ # work if all of the files you specified have valid directory paths at startup time.
109
+ # Note that the default implementation of this feature is based on polling the filesystem,
110
+ # which may not perform well. If you install the 'listen' gem (not included by default, to
111
+ # avoid adding unwanted dependencies to the SDK), its native file watching mechanism will be
112
+ # used instead. However, 'listen' will not be used in JRuby 9.1 due to a known instability.
113
+ # @option options [Float] :poll_interval The minimum interval, in seconds, between checks for
114
+ # file modifications - used only if auto_update is true, and if the native file-watching
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}
117
+ #
118
+ def self.factory(options={})
119
+ return lambda { |sdk_key, config| FileDataSourceImpl.new(config.feature_store, config.logger, options) }
120
+ end
121
+ end
122
+
123
+ # @private
124
+ class FileDataSourceImpl
125
+ def initialize(feature_store, logger, options={})
126
+ @feature_store = feature_store
127
+ @logger = logger
128
+ @paths = options[:paths] || []
129
+ if @paths.is_a? String
130
+ @paths = [ @paths ]
131
+ end
132
+ @auto_update = options[:auto_update]
133
+ if @auto_update && LaunchDarkly.have_listen? && !options[:force_polling] # force_polling is used only for tests
134
+ # We have seen unreliable behavior in the 'listen' gem in JRuby 9.1 (https://github.com/guard/listen/issues/449).
135
+ # Therefore, on that platform we'll fall back to file polling instead.
136
+ if defined?(JRUBY_VERSION) && JRUBY_VERSION.start_with?("9.1.")
137
+ @use_listen = false
138
+ else
139
+ @use_listen = true
140
+ end
141
+ end
142
+ @poll_interval = options[:poll_interval] || 1
143
+ @initialized = Concurrent::AtomicBoolean.new(false)
144
+ @ready = Concurrent::Event.new
145
+ end
146
+
147
+ def initialized?
148
+ @initialized.value
149
+ end
150
+
151
+ def start
152
+ ready = Concurrent::Event.new
153
+
154
+ # We will return immediately regardless of whether the file load succeeded or failed -
155
+ # the difference can be detected by checking "initialized?"
156
+ ready.set
157
+
158
+ load_all
159
+
160
+ if @auto_update
161
+ # If we're going to watch files, then the start event will be set the first time we get
162
+ # a successful load.
163
+ @listener = start_listener
164
+ end
165
+
166
+ ready
167
+ end
168
+
169
+ def stop
170
+ @listener.stop if !@listener.nil?
171
+ end
172
+
173
+ private
174
+
175
+ def load_all
176
+ all_data = {
177
+ FEATURES => {},
178
+ SEGMENTS => {}
179
+ }
180
+ @paths.each do |path|
181
+ begin
182
+ load_file(path, all_data)
183
+ rescue => exn
184
+ Util.log_exception(@logger, "Unable to load flag data from \"#{path}\"", exn)
185
+ return
186
+ end
187
+ end
188
+ @feature_store.init(all_data)
189
+ @initialized.make_true
190
+ end
191
+
192
+ def load_file(path, all_data)
193
+ parsed = parse_content(IO.read(path))
194
+ (parsed[:flags] || {}).each do |key, flag|
195
+ add_item(all_data, FEATURES, flag)
196
+ end
197
+ (parsed[:flagValues] || {}).each do |key, value|
198
+ add_item(all_data, FEATURES, make_flag_with_value(key.to_s, value))
199
+ end
200
+ (parsed[:segments] || {}).each do |key, segment|
201
+ add_item(all_data, SEGMENTS, segment)
202
+ end
203
+ end
204
+
205
+ def parse_content(content)
206
+ # We can use the Ruby YAML parser for both YAML and JSON (JSON is a subset of YAML and while
207
+ # not all YAML parsers handle it correctly, we have verified that the Ruby one does, at least
208
+ # for all the samples of actual flag data that we've tested).
209
+ symbolize_all_keys(YAML.load(content))
210
+ end
211
+
212
+ def symbolize_all_keys(value)
213
+ # This is necessary because YAML.load doesn't have an option for parsing keys as symbols, and
214
+ # the SDK expects all objects to be formatted that way.
215
+ if value.is_a?(Hash)
216
+ value.map{ |k, v| [k.to_sym, symbolize_all_keys(v)] }.to_h
217
+ elsif value.is_a?(Array)
218
+ value.map{ |v| symbolize_all_keys(v) }
219
+ else
220
+ value
221
+ end
222
+ end
223
+
224
+ def add_item(all_data, kind, item)
225
+ items = all_data[kind]
226
+ raise ArgumentError, "Received unknown item kind #{kind} in add_data" if items.nil? # shouldn't be possible since we preinitialize the hash
227
+ key = item[:key].to_sym
228
+ if !items[key].nil?
229
+ raise ArgumentError, "#{kind[:namespace]} key \"#{item[:key]}\" was used more than once"
230
+ end
231
+ items[key] = item
232
+ end
233
+
234
+ def make_flag_with_value(key, value)
235
+ {
236
+ key: key,
237
+ on: true,
238
+ fallthrough: { variation: 0 },
239
+ variations: [ value ]
240
+ }
241
+ end
242
+
243
+ def start_listener
244
+ resolved_paths = @paths.map { |p| Pathname.new(File.absolute_path(p)).realpath.to_s }
245
+ if @use_listen
246
+ start_listener_with_listen_gem(resolved_paths)
247
+ else
248
+ FileDataSourcePoller.new(resolved_paths, @poll_interval, self.method(:load_all), @logger)
249
+ end
250
+ end
251
+
252
+ def start_listener_with_listen_gem(resolved_paths)
253
+ path_set = resolved_paths.to_set
254
+ dir_paths = resolved_paths.map{ |p| File.dirname(p) }.uniq
255
+ opts = { latency: @poll_interval }
256
+ l = Listen.to(*dir_paths, opts) do |modified, added, removed|
257
+ paths = modified + added + removed
258
+ if paths.any? { |p| path_set.include?(p) }
259
+ load_all
260
+ end
261
+ end
262
+ l.start
263
+ l
264
+ end
265
+
266
+ #
267
+ # Used internally by FileDataSource to track data file changes if the 'listen' gem is not available.
268
+ #
269
+ class FileDataSourcePoller
270
+ def initialize(resolved_paths, interval, reloader, logger)
271
+ @stopped = Concurrent::AtomicBoolean.new(false)
272
+ get_file_times = Proc.new do
273
+ ret = {}
274
+ resolved_paths.each do |path|
275
+ begin
276
+ ret[path] = File.mtime(path)
277
+ rescue Errno::ENOENT
278
+ ret[path] = nil
279
+ end
280
+ end
281
+ ret
282
+ end
283
+ last_times = get_file_times.call
284
+ @thread = Thread.new do
285
+ while true
286
+ sleep interval
287
+ break if @stopped.value
288
+ begin
289
+ new_times = get_file_times.call
290
+ changed = false
291
+ last_times.each do |path, old_time|
292
+ new_time = new_times[path]
293
+ if !new_time.nil? && new_time != old_time
294
+ changed = true
295
+ break
296
+ end
297
+ end
298
+ reloader.call if changed
299
+ rescue => exn
300
+ Util.log_exception(logger, "Unexpected exception in FileDataSourcePoller", exn)
301
+ end
302
+ end
303
+ end
304
+ end
305
+
306
+ def stop
307
+ @stopped.make_true
308
+ @thread.run # wakes it up if it's sleeping
309
+ end
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,76 @@
1
+ require 'json'
2
+
3
+ module LaunchDarkly
4
+ #
5
+ # A snapshot of the state of all feature flags with regard to a specific user, generated by
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
+ # bootstrapping the LaunchDarkly JavaScript client.
9
+ #
10
+ class FeatureFlagsState
11
+ def initialize(valid)
12
+ @flag_values = {}
13
+ @flag_metadata = {}
14
+ @valid = valid
15
+ end
16
+
17
+ # Used internally to build the state map.
18
+ # @private
19
+ def add_flag(flag, value, variation, reason = nil, details_only_if_tracked = false)
20
+ key = flag[:key]
21
+ @flag_values[key] = value
22
+ meta = {}
23
+ with_details = !details_only_if_tracked || flag[:trackEvents]
24
+ if !with_details && flag[:debugEventsUntilDate]
25
+ with_details = flag[:debugEventsUntilDate] > (Time.now.to_f * 1000).to_i
26
+ end
27
+ if with_details
28
+ meta[:version] = flag[:version]
29
+ meta[:reason] = reason if !reason.nil?
30
+ end
31
+ meta[:variation] = variation if !variation.nil?
32
+ meta[:trackEvents] = true if flag[:trackEvents]
33
+ meta[:debugEventsUntilDate] = flag[:debugEventsUntilDate] if flag[:debugEventsUntilDate]
34
+ @flag_metadata[key] = meta
35
+ end
36
+
37
+ # Returns true if this object contains a valid snapshot of feature flag state, or false if the
38
+ # state could not be computed (for instance, because the client was offline or there was no user).
39
+ def valid?
40
+ @valid
41
+ end
42
+
43
+ # Returns the value of an individual feature flag at the time the state was recorded.
44
+ # Returns nil if the flag returned the default value, or if there was no such flag.
45
+ def flag_value(key)
46
+ @flag_values[key]
47
+ end
48
+
49
+ # Returns a map of flag keys to flag values. If a flag would have evaluated to the default value,
50
+ # its value will be nil.
51
+ #
52
+ # Do not use this method if you are passing data to the front end to "bootstrap" the JavaScript client.
53
+ # Instead, use as_json.
54
+ def values_map
55
+ @flag_values
56
+ end
57
+
58
+ # Returns a hash that can be used as a JSON representation of the entire state map, in the format
59
+ # used by the LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end
60
+ # in order to "bootstrap" the JavaScript client.
61
+ #
62
+ # Do not rely on the exact shape of this data, as it may change in future to support the needs of
63
+ # the JavaScript client.
64
+ def as_json(*) # parameter is unused, but may be passed if we're using the json gem
65
+ ret = @flag_values.clone
66
+ ret['$flagsState'] = @flag_metadata
67
+ ret['$valid'] = @valid
68
+ ret
69
+ end
70
+
71
+ # Same as as_json, but converts the JSON structure into a string.
72
+ def to_json(*a)
73
+ as_json.to_json(a)
74
+ end
75
+ end
76
+ end