launchdarkly-server-sdk 5.5.7

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