fluidfeatures 0.4.1 → 0.4.4
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.
- data/.gitignore +2 -0
- data/.travis.yml +7 -0
- data/Guardfile +6 -0
- data/Rakefile +3 -0
- data/fluidfeatures.gemspec +7 -0
- data/lib/fluidfeatures/app/feature.rb +2 -2
- data/lib/fluidfeatures/app/reporter.rb +13 -14
- data/lib/fluidfeatures/app/state.rb +11 -12
- data/lib/fluidfeatures/app/transaction.rb +4 -5
- data/lib/fluidfeatures/app/user.rb +1 -1
- data/lib/fluidfeatures/client.rb +3 -2
- data/lib/fluidfeatures/version.rb +1 -1
- data/spec/app/feature_spec.rb +58 -0
- data/spec/app/reporter_spec.rb +127 -0
- data/spec/app/state_spec.rb +75 -0
- data/spec/app/transaction_spec.rb +127 -0
- data/spec/app/user_spec.rb +69 -0
- data/spec/app_spec.rb +88 -0
- data/spec/cassettes/feature.yml +401 -0
- data/spec/cassettes/goal.yml +441 -0
- data/spec/fluidfeatures_spec.rb +53 -0
- data/spec/spec_helper.rb +28 -0
- data/spec/support/api_helpers.rb +27 -0
- data/spec/support/polling_loop_shared_examples.rb +42 -0
- metadata +114 -2
data/.gitignore
CHANGED
data/.travis.yml
ADDED
data/Guardfile
ADDED
data/Rakefile
ADDED
data/fluidfeatures.gemspec
CHANGED
@@ -15,4 +15,11 @@ Gem::Specification.new do |s|
|
|
15
15
|
s.require_paths = ["lib"]
|
16
16
|
s.add_dependency "persistent_http", "~>1.0.3"
|
17
17
|
s.add_dependency "uuid", "~>2.3.5"
|
18
|
+
|
19
|
+
s.add_development_dependency('rake', '~> 10.0.2')
|
20
|
+
s.add_development_dependency('rspec', '~> 2.12.0')
|
21
|
+
s.add_development_dependency('guard-rspec', '~> 2.2.1')
|
22
|
+
s.add_development_dependency('rb-inotify', '~> 0.8.8')
|
23
|
+
s.add_development_dependency('vcr', '~> 2.3.0')
|
24
|
+
s.add_development_dependency('fakeweb', '~> 1.3.0')
|
18
25
|
end
|
@@ -15,7 +15,7 @@ module FluidFeatures
|
|
15
15
|
version_name ||= ::FluidFeatures::DEFAULT_VERSION_NAME
|
16
16
|
raise "version_name invalid : #{version_name}" unless version_name.is_a? String
|
17
17
|
|
18
|
-
@app =
|
18
|
+
@app = app
|
19
19
|
@feature_name = feature_name
|
20
20
|
@version_name = version_name
|
21
21
|
|
@@ -34,7 +34,7 @@ module FluidFeatures
|
|
34
34
|
|
35
35
|
app.put("/feature/#{feature_name}/#{version_name}/enabled/percent", {
|
36
36
|
:enabled => {
|
37
|
-
:percent =>
|
37
|
+
:percent => percent
|
38
38
|
}
|
39
39
|
})
|
40
40
|
end
|
@@ -1,4 +1,3 @@
|
|
1
|
-
|
2
1
|
require "fluidfeatures/const"
|
3
2
|
require "thread"
|
4
3
|
|
@@ -27,9 +26,12 @@ module FluidFeatures
|
|
27
26
|
WAIT_BETWEEN_SEND_FAILURES = 5 # seconds
|
28
27
|
|
29
28
|
def initialize(app)
|
30
|
-
|
31
29
|
raise "app invalid : #{app}" unless app.is_a? ::FluidFeatures::App
|
30
|
+
configure(app)
|
31
|
+
run_loop
|
32
|
+
end
|
32
33
|
|
34
|
+
def configure(app)
|
33
35
|
@app = app
|
34
36
|
|
35
37
|
@buckets = []
|
@@ -41,13 +43,10 @@ module FluidFeatures
|
|
41
43
|
|
42
44
|
@unknown_features = {}
|
43
45
|
@unknown_features_lock = ::Mutex.new
|
44
|
-
|
45
|
-
run_transcation_sender
|
46
|
-
|
47
46
|
end
|
48
47
|
|
49
|
-
# Pass FluidFeatures::
|
50
|
-
# FluidFeatures service.
|
48
|
+
# Pass FluidFeatures::AppUserTransaction for reporting
|
49
|
+
# back to the FluidFeatures service.
|
51
50
|
def report_transaction(transaction)
|
52
51
|
|
53
52
|
user = transaction.user
|
@@ -81,7 +80,7 @@ module FluidFeatures
|
|
81
80
|
|
82
81
|
end
|
83
82
|
|
84
|
-
def
|
83
|
+
def run_loop
|
85
84
|
Thread.new do
|
86
85
|
while true
|
87
86
|
begin
|
@@ -91,7 +90,7 @@ module FluidFeatures
|
|
91
90
|
next
|
92
91
|
end
|
93
92
|
|
94
|
-
success =
|
93
|
+
success = send_transactions
|
95
94
|
|
96
95
|
if success
|
97
96
|
# Unless we have a full bucket waiting do not make
|
@@ -109,7 +108,7 @@ module FluidFeatures
|
|
109
108
|
|
110
109
|
rescue Exception => err
|
111
110
|
# catch errors, so that we do not affect the rest of the application
|
112
|
-
app.logger.error "[FF]
|
111
|
+
app.logger.error "[FF] send_transactions failed : #{err.message}\n#{err.backtrace.join("\n")}"
|
113
112
|
# hold off for a little while and try again
|
114
113
|
sleep WAIT_BETWEEN_SEND_FAILURES
|
115
114
|
end
|
@@ -135,9 +134,9 @@ module FluidFeatures
|
|
135
134
|
end
|
136
135
|
|
137
136
|
@private
|
138
|
-
def
|
137
|
+
def send_transactions
|
139
138
|
bucket = remove_bucket
|
140
|
-
|
139
|
+
|
141
140
|
# Take existing unknown features and reset
|
142
141
|
unknown_features = nil
|
143
142
|
@unknown_features_lock.synchronize do
|
@@ -173,7 +172,7 @@ module FluidFeatures
|
|
173
172
|
unless success
|
174
173
|
# return bucket into bucket queue until the next attempt at sending
|
175
174
|
if not unremove_bucket(bucket)
|
176
|
-
app.logger.warn "[FF] Discarded
|
175
|
+
app.logger.warn "[FF] Discarded transactions due to reporter backlog. These will not be reported to FluidFeatures."
|
177
176
|
end
|
178
177
|
# return unknown features to queue until the next attempt at sending
|
179
178
|
queue_unknown_features(unknown_features)
|
@@ -251,7 +250,7 @@ module FluidFeatures
|
|
251
250
|
def queue_unknown_features(unknown_features)
|
252
251
|
raise "unknown_features should be a Hash" unless unknown_features.is_a? Hash
|
253
252
|
unknown_features.each_pair do |feature_name, versions|
|
254
|
-
raise "unknown_features values should be Hash. versions=#{versions}" unless versions.is_a? Hash
|
253
|
+
raise "unknown_features values should be a Hash. versions=#{versions}" unless versions.is_a? Hash
|
255
254
|
end
|
256
255
|
@unknown_features_lock.synchronize do
|
257
256
|
unknown_features.each_pair do |feature_name, versions|
|
@@ -1,4 +1,3 @@
|
|
1
|
-
|
2
1
|
require "digest/sha1"
|
3
2
|
require "set"
|
4
3
|
require "thread"
|
@@ -8,10 +7,11 @@ require "fluidfeatures/app/transaction"
|
|
8
7
|
|
9
8
|
module FluidFeatures
|
10
9
|
class AppState
|
11
|
-
|
10
|
+
|
12
11
|
attr_accessor :app
|
13
|
-
|
14
|
-
|
12
|
+
|
13
|
+
USER_ID_NUMERIC = /^\d+$/
|
14
|
+
|
15
15
|
# Request to FluidFeatures API to long-poll for max
|
16
16
|
# 30 seconds. The API may choose a different duration.
|
17
17
|
# If not change in this time, API will return HTTP 304.
|
@@ -28,15 +28,15 @@ module FluidFeatures
|
|
28
28
|
WAIT_BETWEEN_FETCH_FAILURES = 5 # seconds
|
29
29
|
|
30
30
|
def initialize(app)
|
31
|
+
raise "app invalid : #{app}" unless app.is_a? ::FluidFeatures::App
|
32
|
+
configure(app)
|
33
|
+
run_loop
|
34
|
+
end
|
31
35
|
|
32
|
-
|
33
|
-
|
36
|
+
def configure(app)
|
34
37
|
@app = app
|
35
38
|
@features = {}
|
36
39
|
@features_lock = ::Mutex.new
|
37
|
-
|
38
|
-
run_state_fetcher
|
39
|
-
|
40
40
|
end
|
41
41
|
|
42
42
|
def features
|
@@ -54,7 +54,7 @@ module FluidFeatures
|
|
54
54
|
end
|
55
55
|
end
|
56
56
|
|
57
|
-
def
|
57
|
+
def run_loop
|
58
58
|
Thread.new do
|
59
59
|
while true
|
60
60
|
begin
|
@@ -105,7 +105,7 @@ module FluidFeatures
|
|
105
105
|
raise "version_name invalid : #{version_name}" unless version_name.is_a? String
|
106
106
|
|
107
107
|
#assert(isinstance(user_id, basestring))
|
108
|
-
|
108
|
+
|
109
109
|
user_attributes ||= {}
|
110
110
|
user_attributes["user"] = user_id.to_s
|
111
111
|
if user_id.is_a? Integer
|
@@ -122,7 +122,6 @@ module FluidFeatures
|
|
122
122
|
modulus = user_id_hash % feature["num_parts"]
|
123
123
|
enabled = version["parts"].include? modulus
|
124
124
|
|
125
|
-
# check attributes
|
126
125
|
feature["versions"].each_pair do |other_version_name, other_version|
|
127
126
|
if other_version
|
128
127
|
version_attributes = (other_version["enabled"] || {})["attributes"]
|
@@ -12,7 +12,7 @@ module FluidFeatures
|
|
12
12
|
@url = url
|
13
13
|
|
14
14
|
# take a snap-shot of the features end at
|
15
|
-
# the beginning of the
|
15
|
+
# the beginning of the transactionapplication
|
16
16
|
@features = user.features
|
17
17
|
|
18
18
|
@features_hit = {}
|
@@ -99,11 +99,10 @@ module FluidFeatures
|
|
99
99
|
# so that FluidFeatures can auto-populate the dashboard.
|
100
100
|
#
|
101
101
|
def end_transaction
|
102
|
-
|
103
|
-
|
104
|
-
@ended = true
|
105
|
-
@duration = Time.now - start_time
|
102
|
+
raise "transaction ended" if ended
|
103
|
+
@duration = duration #Time.now - start_time
|
106
104
|
user.app.reporter.report_transaction(self)
|
105
|
+
@ended = true
|
107
106
|
end
|
108
107
|
|
109
108
|
end
|
@@ -76,7 +76,7 @@ module FluidFeatures
|
|
76
76
|
end
|
77
77
|
|
78
78
|
if ENV["FLUIDFEATURES_USER_FEATURES_FROM_API"]
|
79
|
-
features_enabled = get("/features", attribute_ids) || {}
|
79
|
+
success, features_enabled = get("/features", attribute_ids) || {}
|
80
80
|
else
|
81
81
|
features_enabled = {}
|
82
82
|
app.state.features.each do |feature_name, feature|
|
data/lib/fluidfeatures/client.rb
CHANGED
@@ -4,6 +4,7 @@ require "persistent_http"
|
|
4
4
|
require "pre_ruby192/uri" if RUBY_VERSION < "1.9.2"
|
5
5
|
require "thread"
|
6
6
|
require "uuid"
|
7
|
+
require "json"
|
7
8
|
|
8
9
|
require "fluidfeatures/app"
|
9
10
|
|
@@ -149,14 +150,14 @@ module FluidFeatures
|
|
149
150
|
success = false
|
150
151
|
begin
|
151
152
|
|
152
|
-
request = Net::HTTP::Put.new
|
153
|
+
request = Net::HTTP::Put.new url_path
|
153
154
|
request["Authorization"] = auth_token
|
154
155
|
request["Accept"] = "application/json"
|
155
156
|
request["Accept-Encoding"] = "gzip"
|
156
157
|
encode_request_body(request, payload)
|
157
158
|
|
158
159
|
request_start_time = Time.now
|
159
|
-
response = @http.request
|
160
|
+
response = @http.request request
|
160
161
|
duration = Time.now - request_start_time
|
161
162
|
|
162
163
|
raise "expected Net::HTTPResponse" if not response.is_a? Net::HTTPResponse
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe FluidFeatures::AppFeatureVersion do
|
4
|
+
|
5
|
+
let(:app) { mock "FluidFeatures::App", client: mock("client", uuid: 'client uuid'), logger: mock('logger') }
|
6
|
+
|
7
|
+
before(:each) do
|
8
|
+
app.stub!(:is_a?).and_return(false)
|
9
|
+
app.stub!(:is_a?).with(FluidFeatures::App).and_return(true)
|
10
|
+
end
|
11
|
+
|
12
|
+
context "initialization" do
|
13
|
+
|
14
|
+
it "should raise error if invalid application passed" do
|
15
|
+
app.stub!(:is_a?).with(FluidFeatures::App).and_return(false)
|
16
|
+
expect { described_class.new(app, "Feature", "a") }.to raise_error /app invalid/
|
17
|
+
end
|
18
|
+
|
19
|
+
it "should raise error if invalid feature name passed" do
|
20
|
+
expect { described_class.new(app, 0, "a") }.to raise_error /feature_name invalid/
|
21
|
+
|
22
|
+
end
|
23
|
+
|
24
|
+
it "should raise error if invalid version name passed" do
|
25
|
+
expect { described_class.new(app, "Feature", 0) }.to raise_error /version_name invalid/
|
26
|
+
end
|
27
|
+
|
28
|
+
it "should use default version name if omitted" do
|
29
|
+
described_class.new(app, "Feature").version_name.should == described_class::DEFAULT_VERSION_NAME
|
30
|
+
end
|
31
|
+
|
32
|
+
it "should initialize instance variables with passed values" do
|
33
|
+
feature = described_class.new(app, "Feature", "a")
|
34
|
+
feature.app.should == app
|
35
|
+
feature.feature_name.should == "Feature"
|
36
|
+
feature.version_name.should == "a"
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
|
41
|
+
describe "#set_enabled_percent" do
|
42
|
+
|
43
|
+
let(:feature) { described_class.new(app, "Feature", "a") }
|
44
|
+
|
45
|
+
["50", 120, -12.2, nil].each do |invalid_percent|
|
46
|
+
it "should raise error if '#{invalid_percent}' passed as percentage value" do
|
47
|
+
expect { feature.set_enabled_percent(invalid_percent) }.to raise_error /percent invalid/
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
it "should update percentage on server" do
|
52
|
+
app.should_receive(:put).with("/feature/Feature/a/enabled/percent", {:enabled=>{:percent=>44}})
|
53
|
+
feature.set_enabled_percent(44)
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe FluidFeatures::AppReporter do
|
4
|
+
|
5
|
+
it_should_behave_like "polling loop", :send_transactions do
|
6
|
+
before(:each) { described_class.any_instance.stub(:transactions_queued?).and_return(true) }
|
7
|
+
end
|
8
|
+
|
9
|
+
context do
|
10
|
+
|
11
|
+
let(:reporter) { described_class.new(app) }
|
12
|
+
|
13
|
+
let(:user) { mock('user', unique_id: 'unique id', display_name: 'John Doe', anonymous: false, unique_attrs: [], cohort_attrs: []) }
|
14
|
+
|
15
|
+
let(:transaction) { mock('transaction', url: 'http://example.com/source.html', duration: 999, user: user, unknown_features: [], features_hit: %w[feature], goals_hit: %w[goal]) }
|
16
|
+
|
17
|
+
before(:each) do
|
18
|
+
described_class.any_instance.stub(:run_loop)
|
19
|
+
end
|
20
|
+
|
21
|
+
describe "#report_transaction" do
|
22
|
+
|
23
|
+
before(:each) do
|
24
|
+
reporter.stub!(:queue_transaction_payload)
|
25
|
+
reporter.stub!(:queue_unknown_features)
|
26
|
+
end
|
27
|
+
|
28
|
+
it "should queue transaction payload" do
|
29
|
+
reporter.should_not_receive(:queue_unknown_features)
|
30
|
+
reporter.should_receive(:queue_transaction_payload).with(
|
31
|
+
url: "http://example.com/source.html",
|
32
|
+
user: { id: "unique id", name: "John Doe", unique: [], cohorts: [] },
|
33
|
+
hits: { feature: ["feature"], goal: ["goal"] },
|
34
|
+
stats: { duration: 999 }
|
35
|
+
)
|
36
|
+
reporter.report_transaction(transaction)
|
37
|
+
end
|
38
|
+
|
39
|
+
it "should queue unknown features if any" do
|
40
|
+
transaction.stub!(:unknown_features).and_return(%w[feature])
|
41
|
+
reporter.should_receive(:queue_unknown_features).with(transaction.unknown_features)
|
42
|
+
reporter.report_transaction(transaction)
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
|
47
|
+
describe "#transactions_queued?" do
|
48
|
+
before(:each) do
|
49
|
+
reporter.configure(app)
|
50
|
+
end
|
51
|
+
|
52
|
+
it "should return false if no transaction queued" do
|
53
|
+
reporter.transactions_queued?.should be_false
|
54
|
+
end
|
55
|
+
|
56
|
+
it "should return true if at least one transaction queued" do
|
57
|
+
reporter.instance_variable_set(:@buckets, %w[bucket])
|
58
|
+
reporter.instance_variable_set(:@current_bucket, %w[payload])
|
59
|
+
reporter.transactions_queued?.should be_true
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
describe "#queue_transaction_payload" do
|
64
|
+
it "should push transaction payload to current bucket" do
|
65
|
+
reporter.instance_variable_set(:@current_bucket, [])
|
66
|
+
reporter.queue_transaction_payload({ foo: 'bar' })
|
67
|
+
reporter.instance_variable_get(:@current_bucket).should == [{ foo: 'bar' }]
|
68
|
+
end
|
69
|
+
|
70
|
+
it "should create new bucket if current bucket is full" do
|
71
|
+
reporter.instance_variable_set(:@current_bucket, [])
|
72
|
+
current_bucket = reporter.instance_variable_get(:@current_bucket)
|
73
|
+
current_bucket.stub!(:size).and_return(described_class::MAX_BUCKET_SIZE + 1)
|
74
|
+
reporter.should_receive(:new_bucket).and_return([])
|
75
|
+
reporter.queue_transaction_payload({ foo: 'bar' })
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
describe "#queue_unknown_features" do
|
80
|
+
let(:unknown_features) { { "feature" => { "a" => true } } }
|
81
|
+
it "should add unknown_features to instance variable" do
|
82
|
+
reporter.queue_unknown_features(unknown_features)
|
83
|
+
reporter.instance_variable_get(:@unknown_features).should == unknown_features
|
84
|
+
end
|
85
|
+
|
86
|
+
it "should raise error if unknown_features is not Hash" do
|
87
|
+
expect { reporter.queue_unknown_features("not cool") }.to raise_error /should be a Hash/
|
88
|
+
end
|
89
|
+
|
90
|
+
it "should raise error if versions is not Hash" do
|
91
|
+
expect { reporter.queue_unknown_features("feature" => "not cool") }.to raise_error /should be a Hash/
|
92
|
+
end
|
93
|
+
|
94
|
+
end
|
95
|
+
|
96
|
+
describe "#send_transactions" do
|
97
|
+
let(:unknown_features) { { "feature" => { "a" => true } } }
|
98
|
+
let(:app) { mock "FluidFeatures::App", client: mock("client", uuid: 'client uuid'), logger: mock('logger') }
|
99
|
+
|
100
|
+
before(:each) do
|
101
|
+
app.stub!(:is_a?).and_return(false)
|
102
|
+
app.stub!(:is_a?).with(FluidFeatures::App).and_return(true)
|
103
|
+
app.client.stub(:siphon_api_request_log).and_return("api log")
|
104
|
+
app.stub!(:post)
|
105
|
+
reporter.stub(:remove_bucket).and_return(["transactions"])
|
106
|
+
reporter.instance_variable_set(:@unknown_features, unknown_features)
|
107
|
+
reporter.instance_variable_set(:@buckets, [%w[one], %w[one two]])
|
108
|
+
end
|
109
|
+
|
110
|
+
it "should send transaction to server" do
|
111
|
+
app.should_receive(:post).with("/report/transactions", :client_uuid=>"client uuid", :transactions=>["transactions"], :stats=>{:waiting_buckets=>[1, 2]}, :unknown_features=>{"feature"=>{"a"=>true}}, :api_request_log=>"api log").and_return(true)
|
112
|
+
reporter.send_transactions
|
113
|
+
end
|
114
|
+
|
115
|
+
it "should emit warning and queue unknown features on failure" do
|
116
|
+
app.stub!(:post).and_return(false)
|
117
|
+
reporter.stub!(:unremove_bucket).and_return(false)
|
118
|
+
app.logger.should_receive(:warn)
|
119
|
+
reporter.should_receive(:queue_unknown_features).with(unknown_features)
|
120
|
+
reporter.send_transactions
|
121
|
+
end
|
122
|
+
|
123
|
+
end
|
124
|
+
|
125
|
+
end
|
126
|
+
|
127
|
+
end
|