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