ldclient-rb 0.4.0 → 0.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.
@@ -1,19 +1,16 @@
1
1
  module LaunchDarkly
2
-
3
2
  class LDNewRelic
4
3
  begin
5
- require 'newrelic_rpm'
4
+ require "newrelic_rpm"
6
5
  NR_ENABLED = defined?(::NewRelic::Agent.add_custom_parameters)
7
- rescue Exception
6
+ rescue ScriptError, StandardError
8
7
  NR_ENABLED = false
9
8
  end
10
9
 
11
10
  def self.annotate_transaction(key, value)
12
11
  if NR_ENABLED
13
- ::NewRelic::Agent.add_custom_parameters({key.to_s => value.to_s})
12
+ ::NewRelic::Agent.add_custom_parameters(key.to_s => value.to_s)
14
13
  end
15
14
  end
16
15
  end
17
-
18
-
19
- end
16
+ end
@@ -1,37 +1,36 @@
1
- require 'thread_safe'
1
+ require "thread_safe"
2
2
 
3
3
  module LaunchDarkly
4
-
5
4
  # A thread-safe in-memory store suitable for use
6
5
  # with the Faraday caching HTTP client. Uses the
7
- # Threadsafe gem as the underlying cache.
8
- #
6
+ # Threadsafe gem as the underlying cache.
7
+ #
9
8
  # @see https://github.com/plataformatec/faraday-http-cache
10
9
  # @see https://github.com/ruby-concurrency/thread_safe
11
- #
10
+ #
12
11
  class ThreadSafeMemoryStore
13
- #
12
+ #
14
13
  # Default constructor
15
- #
14
+ #
16
15
  # @return [ThreadSafeMemoryStore] a new store
17
16
  def initialize
18
17
  @cache = ThreadSafe::Cache.new
19
18
  end
20
19
 
21
- #
20
+ #
22
21
  # Read a value from the cache
23
22
  # @param key [Object] the cache key
24
- #
23
+ #
25
24
  # @return [Object] the cache value
26
25
  def read(key)
27
26
  @cache[key]
28
27
  end
29
28
 
30
- #
29
+ #
31
30
  # Store a value in the cache
32
31
  # @param key [Object] the cache key
33
32
  # @param value [Object] the value to associate with the key
34
- #
33
+ #
35
34
  # @return [Object] the value
36
35
  def write(key, value)
37
36
  @cache[key] = value
@@ -1,67 +1,66 @@
1
- require 'concurrent/atomics'
2
- require 'json'
3
- require 'ld-em-eventsource'
1
+ require "concurrent/atomics"
2
+ require "json"
3
+ require "ld-em-eventsource"
4
4
 
5
5
  module LaunchDarkly
6
-
7
6
  PUT = "put"
8
7
  PATCH = "patch"
9
8
  DELETE = "delete"
10
9
 
11
10
  class InMemoryFeatureStore
12
- def initialize()
11
+ def initialize
13
12
  @features = Hash.new
14
13
  @lock = Concurrent::ReadWriteLock.new
15
14
  @initialized = Concurrent::AtomicBoolean.new(false)
16
15
  end
17
16
 
18
17
  def get(key)
19
- @lock.with_read_lock {
18
+ @lock.with_read_lock do
20
19
  f = @features[key.to_sym]
21
20
  (f.nil? || f[:deleted]) ? nil : f
22
- }
21
+ end
23
22
  end
24
23
 
25
- def all()
26
- @lock.with_read_lock {
27
- @features.select {|k,f| not f[:deleted]}
28
- }
24
+ def all
25
+ @lock.with_read_lock do
26
+ @features.select { |_k, f| not f[:deleted] }
27
+ end
29
28
  end
30
29
 
31
30
  def delete(key, version)
32
- @lock.with_write_lock {
31
+ @lock.with_write_lock do
33
32
  old = @features[key.to_sym]
34
33
 
35
- if old != nil and old[:version] < version
34
+ if !old.nil? && old[:version] < version
36
35
  old[:deleted] = true
37
36
  old[:version] = version
38
37
  @features[key.to_sym] = old
39
38
  elsif old.nil?
40
- @features[key.to_sym] = {:deleted => true, :version => version}
39
+ @features[key.to_sym] = { deleted: true, version: version }
41
40
  end
42
- }
41
+ end
43
42
  end
44
43
 
45
44
  def init(fs)
46
- @lock.with_write_lock {
45
+ @lock.with_write_lock do
47
46
  @features.replace(fs)
48
47
  @initialized.make_true
49
- }
48
+ end
50
49
  end
51
50
 
52
51
  def upsert(key, feature)
53
- @lock.with_write_lock {
52
+ @lock.with_write_lock do
54
53
  old = @features[key.to_sym]
55
54
 
56
- if old.nil? or old[:version] < feature[:version]
55
+ if old.nil? || old[:version] < feature[:version]
57
56
  @features[key.to_sym] = feature
58
57
  end
59
- }
58
+ end
60
59
  end
61
60
 
62
- def initialized?()
61
+ def initialized?
63
62
  @initialized.value
64
- end
63
+ end
65
64
  end
66
65
 
67
66
  class StreamProcessor
@@ -73,11 +72,11 @@ module LaunchDarkly
73
72
  @started = Concurrent::AtomicBoolean.new(false)
74
73
  end
75
74
 
76
- def initialized?()
75
+ def initialized?
77
76
  @store.initialized?
78
77
  end
79
78
 
80
- def started?()
79
+ def started?
81
80
  @started.value
82
81
  end
83
82
 
@@ -88,9 +87,9 @@ module LaunchDarkly
88
87
  @store.get(key)
89
88
  end
90
89
 
91
- def start_reactor()
90
+ def start_reactor
92
91
  if defined?(Thin)
93
- @config.logger.debug("Running in a Thin environment-- not starting EventMachine")
92
+ @config.logger.debug("Running in a Thin environment-- not starting EventMachine")
94
93
  elsif EM.reactor_running?
95
94
  @config.logger.debug("EventMachine already running")
96
95
  else
@@ -101,66 +100,67 @@ module LaunchDarkly
101
100
  EM.reactor_running?
102
101
  end
103
102
 
104
- def start()
103
+ def start
105
104
  # Try to start the reactor. If it's not started, we shouldn't start
106
105
  # the stream processor
107
- if not start_reactor
108
- return
109
- end
106
+ return if not start_reactor
110
107
 
111
108
  # If someone else booted the stream processor connection, just return
112
- if not @started.make_true
113
- return
114
- end
109
+ return unless @started.make_true
115
110
 
116
111
  # If we're the first and only thread to set started, boot
117
112
  # the stream processor connection
118
113
  EM.defer do
119
- source = EM::EventSource.new(@config.stream_uri + "/features",
120
- {},
121
- {'Accept' => 'text/event-stream',
122
- 'Authorization' => 'api_key ' + @api_key,
123
- 'User-Agent' => 'RubyClient/' + LaunchDarkly::VERSION})
124
- source.on PUT do |message|
125
- features = JSON.parse(message, :symbolize_names => true)
126
- @store.init(features)
127
- set_connected
128
- end
129
- source.on PATCH do |message|
130
- json = JSON.parse(message, :symbolize_names => true)
131
- @store.upsert(json[:path][1..-1], json[:data])
132
- set_connected
133
- end
134
- source.on DELETE do |message|
135
- json = JSON.parse(message, :symbolize_names => true)
136
- @store.delete(json[:path][1..-1], json[:version])
137
- set_connected
138
- end
139
- source.error do |error|
140
- @config.logger.error("[LDClient] Error subscribing to stream API: #{error}")
141
- set_disconnected
142
- end
143
- source.inactivity_timeout = 0
144
- source.start
114
+ boot_event_manager
115
+ end
116
+ end
117
+
118
+ def boot_event_manager
119
+ source = EM::EventSource.new(@config.stream_uri + "/features",
120
+ {},
121
+ "Accept" => "text/event-stream",
122
+ "Authorization" => "api_key " + @api_key,
123
+ "User-Agent" => "RubyClient/" + LaunchDarkly::VERSION)
124
+ source.on(PUT) { |message| process_message(message, PUT) }
125
+ source.on(PATCH) { |message| process_message(message, PATCH) }
126
+ source.on(DELETE) { |message| process_message(message, DELETE) }
127
+ source.error do |error|
128
+ @config.logger.info("[LDClient] Error subscribing to stream API: #{error}")
129
+ set_disconnected
145
130
  end
131
+ source.inactivity_timeout = 0
132
+ source.start
133
+ source
146
134
  end
147
135
 
148
- def set_disconnected()
136
+ def process_message(message, method)
137
+ message = JSON.parse(message, symbolize_names: true)
138
+ if method == PUT
139
+ @store.init(message)
140
+ elsif method == PATCH
141
+ @store.upsert(message[:path][1..-1], message[:data])
142
+ elsif method == DELETE
143
+ @store.delete(message[:path][1..-1], message[:version])
144
+ else
145
+ @config.logger.error("[LDClient] Unknown message received: #{method}")
146
+ end
147
+ set_connected
148
+ end
149
+
150
+ def set_disconnected
149
151
  @disconnected.set(Time.now)
150
152
  end
151
153
 
152
- def set_connected()
154
+ def set_connected
153
155
  @disconnected.set(nil)
154
156
  end
155
157
 
156
- def should_fallback_update()
158
+ def should_fallback_update
157
159
  disc = @disconnected.get
158
- disc != nil and disc < (Time.now - 120)
160
+ !disc.nil? && disc < (Time.now - 120)
159
161
  end
160
162
 
161
163
  # TODO mark private methods
162
- private :set_connected, :set_disconnected, :start_reactor
163
-
164
+ private :boot_event_manager, :process_message, :set_connected, :set_disconnected, :start_reactor
164
165
  end
165
-
166
- end
166
+ end
@@ -1,3 +1,3 @@
1
1
  module LaunchDarkly
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -0,0 +1,45 @@
1
+ require "spec_helper"
2
+
3
+ describe LaunchDarkly::Config do
4
+ subject { LaunchDarkly::Config }
5
+ describe ".initialize" do
6
+ it "can be initialized with default settings" do
7
+ expect(subject).to receive(:default_capacity).and_return 1234
8
+ expect(subject.new.capacity).to eq 1234
9
+ end
10
+ it "accepts custom arguments" do
11
+ expect(subject).to_not receive(:default_capacity)
12
+ expect(subject.new(capacity: 50).capacity).to eq 50
13
+ end
14
+ it "will chomp base_url and stream_uri" do
15
+ uri = "https://test.launchdarkly.com"
16
+ config = subject.new(base_uri: uri + "/")
17
+ expect(config.base_uri).to eq uri
18
+ end
19
+ end
20
+ describe "@base_uri" do
21
+ it "can be read" do
22
+ expect(subject.new.base_uri).to eq subject.default_base_uri
23
+ end
24
+ end
25
+ describe ".default_store" do
26
+ it "uses Rails cache if it is available" do
27
+ rails = instance_double("Rails", cache: :cache)
28
+ stub_const("Rails", rails)
29
+ expect(subject.default_store).to eq :cache
30
+ end
31
+ it "uses memory store if Rails is not available" do
32
+ expect(subject.default_store).to be_an_instance_of LaunchDarkly::ThreadSafeMemoryStore
33
+ end
34
+ end
35
+ describe ".default_logger" do
36
+ it "uses Rails logger if it is available" do
37
+ rails = instance_double("Rails", logger: :logger)
38
+ stub_const("Rails", rails)
39
+ expect(subject.default_logger).to eq :logger
40
+ end
41
+ it "Uses logger if Rails is not available" do
42
+ expect(subject.default_logger).to be_an_instance_of Logger
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,67 @@
1
+ {
2
+ "name":"New recommendations engine",
3
+ "key":"engine.enable",
4
+ "kind":"flag",
5
+ "salt":"ZW5naW5lLmVuYWJsZQ==",
6
+ "on":true,
7
+ "variations":[
8
+ {
9
+ "value":true,
10
+ "weight":31,
11
+ "targets":[
12
+ {
13
+ "attribute":"groups",
14
+ "op":"in",
15
+ "values":[
16
+ "Microsoft"
17
+ ]
18
+ }
19
+ ],
20
+ "userTarget":{
21
+ "attribute":"key",
22
+ "op":"in",
23
+ "values":[
24
+ "foo@bar.com",
25
+ "Abbey.Orkwis@example.com",
26
+ "Abbie.Stolte@example.com",
27
+ "44d2781c-5985-4d89-b07a-0dffbd24126f",
28
+ "0ffe4f0c-7aa9-4621-a87c-abe1c051abd8",
29
+ "f52d99be-6a40-4cdd-a7b4-0548834fcbe5",
30
+ "Vernetta.Belden@example.com",
31
+ "c9d591bd-ea1f-465f-86d2-239ea41d0f0f",
32
+ "870745092"
33
+ ]
34
+ }
35
+ },
36
+ {
37
+ "value":false,
38
+ "weight":69,
39
+ "targets":[
40
+ {
41
+ "attribute":"key",
42
+ "op":"in",
43
+ "values":[
44
+ "Alida.Caples@example.com"
45
+ ]
46
+ },
47
+ {
48
+ "attribute":"groups",
49
+ "op":"in",
50
+ "values":[
51
+
52
+ ]
53
+ }
54
+ ],
55
+ "userTarget":{
56
+ "attribute":"key",
57
+ "op":"in",
58
+ "values":[
59
+ "Alida.Caples@example.com"
60
+ ]
61
+ }
62
+ }
63
+ ],
64
+ "ttl":0,
65
+ "commitDate":"2015-05-10T06:06:45.381Z",
66
+ "creationDate":"2014-09-02T20:39:18.61Z"
67
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "key":"user@test.com",
3
+ "custom":{
4
+ "groups":[
5
+ "microsoft",
6
+ "google"
7
+ ]
8
+ }
9
+ }
@@ -0,0 +1,226 @@
1
+ require "spec_helper"
2
+
3
+ describe LaunchDarkly::LDClient do
4
+ subject { LaunchDarkly::LDClient }
5
+ let(:client) do
6
+ expect_any_instance_of(LaunchDarkly::LDClient).to receive :create_worker
7
+ subject.new("api_key")
8
+ end
9
+ let(:feature) do
10
+ data = File.read(File.join("spec", "fixtures", "feature.json"))
11
+ JSON.parse(data, symbolize_names: true)
12
+ end
13
+ let(:user) do
14
+ data = File.read(File.join("spec", "fixtures", "user.json"))
15
+ JSON.parse(data, symbolize_names: true)
16
+ end
17
+
18
+ describe '#flush' do
19
+ it "will flush and post all events" do
20
+ client.instance_variable_get(:@queue).push "asdf"
21
+ client.instance_variable_get(:@queue).push "asdf"
22
+ expect(client).to receive(:post_flushed_events)
23
+ client.flush
24
+ expect(client.instance_variable_get(:@queue).length).to eq 0
25
+ end
26
+ it "will not do anything if there are no events" do
27
+ expect(client).to_not receive(:post_flushed_events)
28
+ expect(client.instance_variable_get(:@config).logger).to_not receive :error
29
+ client.flush
30
+ end
31
+ end
32
+
33
+ describe '#post_flushed_events' do
34
+ let(:events) { ["event"] }
35
+ it "will flush and post all events" do
36
+ result = double("result", status: 200)
37
+ expect(client.instance_variable_get(:@client)).to receive(:post).and_return result
38
+ expect(client.instance_variable_get(:@config).logger).to_not receive :error
39
+ client.send(:post_flushed_events, events)
40
+ expect(client.instance_variable_get(:@queue).length).to eq 0
41
+ end
42
+ it "will allow any 2XX response" do
43
+ result = double("result", status: 202)
44
+ expect(client.instance_variable_get(:@client)).to receive(:post).and_return result
45
+ expect(client.instance_variable_get(:@config).logger).to_not receive :error
46
+ client.send(:post_flushed_events, events)
47
+ end
48
+ it "will work with unexpected post results" do
49
+ result = double("result", status: 418)
50
+ expect(client.instance_variable_get(:@client)).to receive(:post).and_return result
51
+ expect(client.instance_variable_get(:@config).logger).to receive :error
52
+ client.send(:post_flushed_events, events)
53
+ expect(client.instance_variable_get(:@queue).length).to eq 0
54
+ end
55
+ end
56
+
57
+ describe '#toggle?' do
58
+ it "will not fail" do
59
+ expect(client.instance_variable_get(:@config)).to receive(:stream?).and_raise RuntimeError
60
+ expect(client.instance_variable_get(:@config).logger).to receive(:error)
61
+ result = client.toggle?(feature[:key], user, "default")
62
+ expect(result).to eq "default"
63
+ end
64
+ it "requires user" do
65
+ expect(client.instance_variable_get(:@config).logger).to receive(:error)
66
+ result = client.toggle?(feature[:key], nil, "default")
67
+ expect(result).to eq "default"
68
+ end
69
+ it "returns value from streamed flag if available" do
70
+ expect(client.instance_variable_get(:@config)).to receive(:stream?).and_return(true).twice
71
+ expect(client.instance_variable_get(:@stream_processor)).to receive(:started?).and_return true
72
+ expect(client.instance_variable_get(:@stream_processor)).to receive(:initialized?).and_return true
73
+ expect(client).to receive(:add_event)
74
+ expect(client).to receive(:get_streamed_flag).and_return feature
75
+ result = client.toggle?(feature[:key], user, "default")
76
+ expect(result).to eq false
77
+ end
78
+ it "returns value from normal request if streamed flag is not available" do
79
+ expect(client.instance_variable_get(:@config)).to receive(:stream?).and_return(false).twice
80
+ expect(client).to receive(:add_event)
81
+ expect(client).to receive(:get_flag_int).and_return feature
82
+ result = client.toggle?(feature[:key], user, "default")
83
+ expect(result).to eq false
84
+ end
85
+ end
86
+
87
+ describe '#get_streamed_flag' do
88
+ it "will not check the polled flag normally" do
89
+ expect(client).to receive(:get_flag_stream).and_return true
90
+ expect(client).to_not receive(:get_flag_int)
91
+ expect(client.send(:get_streamed_flag, "key")).to eq true
92
+ end
93
+ context "debug stream" do
94
+ it "will log an error if the streamed and polled flag do not match" do
95
+ expect(client.instance_variable_get(:@config)).to receive(:debug_stream?).and_return true
96
+ expect(client).to receive(:get_flag_stream).and_return true
97
+ expect(client).to receive(:get_flag_int).and_return false
98
+ expect(client.instance_variable_get(:@config).logger).to receive(:error)
99
+ expect(client.send(:get_streamed_flag, "key")).to eq true
100
+ end
101
+ end
102
+ end
103
+
104
+ describe '#get_features' do
105
+ it "will parse and return the features list" do
106
+ result = double("Faraday::Response", status: 200, body: '{"items": ["asdf"]}')
107
+ expect(client).to receive(:make_request).with("/api/features").and_return(result)
108
+ data = client.send(:get_features)
109
+ expect(data).to eq ["asdf"]
110
+ end
111
+ it "will log errors" do
112
+ result = double("Faraday::Response", status: 418)
113
+ expect(client).to receive(:make_request).with("/api/features").and_return(result)
114
+ expect(client.instance_variable_get(:@config).logger).to receive(:error)
115
+ client.send(:get_features)
116
+ end
117
+ end
118
+
119
+ describe '#get_flag_int' do
120
+ it "will return the parsed flag" do
121
+ result = double("Faraday::Response", status: 200, body: '{"asdf":"qwer"}')
122
+ expect(client).to receive(:make_request).with("/api/eval/features/key").and_return(result)
123
+ data = client.send(:get_flag_int, "key")
124
+ expect(data).to eq(asdf: "qwer")
125
+ end
126
+ it "will accept 401 statuses" do
127
+ result = double("Faraday::Response", status: 401)
128
+ expect(client).to receive(:make_request).with("/api/eval/features/key").and_return(result)
129
+ expect(client.instance_variable_get(:@config).logger).to receive(:error)
130
+ data = client.send(:get_flag_int, "key")
131
+ expect(data).to be_nil
132
+ end
133
+ it "will accept 404 statuses" do
134
+ result = double("Faraday::Response", status: 404)
135
+ expect(client).to receive(:make_request).with("/api/eval/features/key").and_return(result)
136
+ expect(client.instance_variable_get(:@config).logger).to receive(:error)
137
+ data = client.send(:get_flag_int, "key")
138
+ expect(data).to be_nil
139
+ end
140
+ it "will accept non-standard statuses" do
141
+ result = double("Faraday::Response", status: 418)
142
+ expect(client).to receive(:make_request).with("/api/eval/features/key").and_return(result)
143
+ expect(client.instance_variable_get(:@config).logger).to receive(:error)
144
+ data = client.send(:get_flag_int, "key")
145
+ expect(data).to be_nil
146
+ end
147
+ end
148
+
149
+ describe '#make_request' do
150
+ it "will make a proper request" do
151
+ expect(client.instance_variable_get :@client).to receive(:get)
152
+ client.send(:make_request, "/asdf")
153
+ end
154
+ end
155
+
156
+ describe '#param_for_user' do
157
+ it "will return a consistent hash of a user key, feature key, and feature salt" do
158
+ param = client.send(:param_for_user, feature, user)
159
+ expect(param).to be_between(0.0, 1.0).inclusive
160
+ end
161
+ end
162
+
163
+ describe '#evaluate' do
164
+ it "will return nil if there is no feature" do
165
+ expect(client.send(:evaluate, nil, user)).to eq nil
166
+ end
167
+ it "will return nil unless the feature is on" do
168
+ feature[:on] = false
169
+ expect(client.send(:evaluate, feature, user)).to eq nil
170
+ end
171
+ it "will return value if it matches the user" do
172
+ user = { key: "Alida.Caples@example.com" }
173
+ expect(client.send(:evaluate, feature, user)).to eq false
174
+ user = { key: "foo@bar.com" }
175
+ expect(client.send(:evaluate, feature, user)).to eq true
176
+ end
177
+ it "will return value if the target matches" do
178
+ user = { key: "asdf@asdf.com", custom: { groups: "Microsoft" } }
179
+ expect(client.send(:evaluate, feature, user)).to eq true
180
+ end
181
+ it "will return value if the weight matches" do
182
+ expect(client).to receive(:param_for_user).and_return 0.1
183
+ expect(client.send(:evaluate, feature, user)).to eq true
184
+ expect(client).to receive(:param_for_user).and_return 0.9
185
+ expect(client.send(:evaluate, feature, user)).to eq false
186
+ end
187
+ end
188
+
189
+ describe '#log_timings' do
190
+ let(:block) { lambda { "result" } }
191
+ let(:label) { "label" }
192
+ it "will not measure if not configured to do so" do
193
+ expect(Benchmark).to_not receive(:measure)
194
+ client.send(:log_timings, label, &block)
195
+ end
196
+ context "logging enabled" do
197
+ before do
198
+ expect(client.instance_variable_get(:@config)).to receive(:log_timings?).and_return true
199
+ expect(client.instance_variable_get(:@config).logger).to receive(:debug?).and_return true
200
+ end
201
+ it "will benchmark timings and return result" do
202
+ expect(Benchmark).to receive(:measure).and_call_original
203
+ expect(client.instance_variable_get(:@config).logger).to receive(:debug)
204
+ result = client.send(:log_timings, label, &block)
205
+ expect(result).to eq "result"
206
+ end
207
+ it "will raise exceptions if the block has them" do
208
+ block = lambda { raise RuntimeError }
209
+ expect(Benchmark).to receive(:measure).and_call_original
210
+ expect(client.instance_variable_get(:@config).logger).to receive(:debug)
211
+ expect { client.send(:log_timings, label, &block) }.to raise_error RuntimeError
212
+ end
213
+ end
214
+ end
215
+
216
+ describe '#log_exception' do
217
+ it "log error data" do
218
+ expect(client.instance_variable_get(:@config).logger).to receive(:error)
219
+ begin
220
+ raise StandardError.new 'asdf'
221
+ rescue StandardError => exn
222
+ client.send(:log_exception, 'caller', exn)
223
+ end
224
+ end
225
+ end
226
+ end