fluidfeatures 0.3.1 → 0.3.4
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +46 -0
- data/fluidfeatures.gemspec +1 -0
- data/lib/fluidfeatures/app/reporter.rb +274 -0
- data/lib/fluidfeatures/app/state.rb +150 -0
- data/lib/fluidfeatures/app/transaction.rb +110 -0
- data/lib/fluidfeatures/app/user.rb +28 -105
- data/lib/fluidfeatures/app.rb +11 -3
- data/lib/fluidfeatures/client.rb +143 -24
- data/lib/fluidfeatures/version.rb +1 -1
- metadata +23 -3
data/LICENSE
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
|
2
|
+
All components of this product are
|
3
|
+
Copyright (c) 2012 FluidFeatures Software Inc. All rights reserved.
|
4
|
+
|
5
|
+
FluidFeatures Software License
|
6
|
+
|
7
|
+
This Software and all content and other materials in the Software or associated
|
8
|
+
with the Software, including, without limitation, all logos, and all designs,
|
9
|
+
text, graphics, pictures, reviews, information, data, software, sound files,
|
10
|
+
other files and the selection and arrangement thereof (collectively, the
|
11
|
+
"Software Materials") are the proprietary property of FluidFeatures Software
|
12
|
+
Inc., or its licensors or users, and are protected by Canada, U.S. and
|
13
|
+
international copyright laws.
|
14
|
+
|
15
|
+
You are granted a limited, non-sublicensable license to access and use the
|
16
|
+
Software, for your commercial use by you and your website users only. The
|
17
|
+
license does not include or authorize: (a) any resale of the Software or the
|
18
|
+
Software Materials therein; (b) modifying or otherwise making any derivative
|
19
|
+
uses of the Software and the Software Materials, or any portion thereof, except
|
20
|
+
where such modification or derivative use is made in connection with your use of
|
21
|
+
the FluidFeatures online service; or (c) any use of the Software or the Software
|
22
|
+
Materials other than for its intended purpose. Any use of the Software or the
|
23
|
+
Software Materials other than as specifically authorized in these license,
|
24
|
+
without the prior written permission of FluidFeatures Software Inc, is strictly
|
25
|
+
prohibited and will terminate the license granted here. Such unauthorized use
|
26
|
+
may also violate applicable laws including without limitation copyright and
|
27
|
+
trademark laws and applicable communications regulations and statutes. Unless
|
28
|
+
explicitly stated herein, nothing in this license shall be construed as
|
29
|
+
conferring any license to intellectual property rights, whether by estoppel,
|
30
|
+
implication or otherwise. This license is revocable at any time for any reason.
|
31
|
+
|
32
|
+
THE SOFTWARE AND THE SOFTWARE MATERIALS (INCLUDING ANY INFORMATION) ARE PROVIDED
|
33
|
+
ON AN "AS IS" AND "AS AVAILABLE" BASIS WITHOUT WARRANTIES OF ANY KIND, EITHER
|
34
|
+
EXPRESS OR IMPLIED, AND INCLUDING WITHOUT LIMITATION IMPLIED REPRESENTATIONS,
|
35
|
+
WARRANTIES OR CONDITIONS OF OR RELATING TO ACCURACY, ACCESSIBILITY,
|
36
|
+
AVAILABILITY, COMPLETENESS, ERRORS, FITNESS FOR A PARTICULAR PURPOSE,
|
37
|
+
MERCHANTABILITY, NON-INFRINGEMENT, PERFORMANCE, QUALITY, RESULTS, SECURITY,
|
38
|
+
SEQUENCE, OR TIMELINESS, ALL OF WHICH ARE HEREBY DISCLAIMED BY FLUIDFEATURES
|
39
|
+
SOFTWARE INC TO THE FULLEST EXTENT PERMITTED BY LAW.
|
40
|
+
|
41
|
+
THE AGGREGATE LIABILITY OF FLUID FEATURES SOFTWARE INC. TO YOU, WHETHER IN
|
42
|
+
CONTRACT, WARRANTY, TORT (INCLUDING NEGLIGENCE, WHETHER ACTIVE, PASSIVE OR
|
43
|
+
IMPUTED), PRODUCT LIABILITY, STRICT LIABILITY OR OTHER THEORY OF LIABILITY,
|
44
|
+
SHALL NOT EXCEED ANY COMPENSATION YOU PAY, IF ANY, FOR ACCESS TO OR USE OF THE
|
45
|
+
SOFTWARE.
|
46
|
+
|
data/fluidfeatures.gemspec
CHANGED
@@ -0,0 +1,274 @@
|
|
1
|
+
|
2
|
+
require "fluidfeatures/const"
|
3
|
+
require "thread"
|
4
|
+
|
5
|
+
module FluidFeatures
|
6
|
+
class AppReporter
|
7
|
+
|
8
|
+
attr_accessor :app
|
9
|
+
|
10
|
+
# Throw away oldest buckets when this limit reached.
|
11
|
+
MAX_BUCKETS = 10
|
12
|
+
|
13
|
+
# Max number of transactions we queue in a bucket.
|
14
|
+
MAX_BUCKET_SIZE = 100
|
15
|
+
|
16
|
+
# While queue is empty we will check size every 0.5 secs
|
17
|
+
WAIT_BETWEEN_QUEUE_EMTPY_CHECKS = 0.5 # seconds
|
18
|
+
|
19
|
+
# Soft max of 1 req/sec
|
20
|
+
WAIT_BETWEEN_SEND_SUCCESS_NONE_WAITING = 1 # seconds
|
21
|
+
|
22
|
+
# Hard max of 10 req/sec
|
23
|
+
WAIT_BETWEEN_SEND_SUCCESS_NEXT_WAITING = 0.1 # seconds
|
24
|
+
|
25
|
+
# If we are failing to communicate with the FluidFeautres API
|
26
|
+
# then wait for this long between requests.
|
27
|
+
WAIT_BETWEEN_SEND_FAILURES = 5 # seconds
|
28
|
+
|
29
|
+
def initialize(app)
|
30
|
+
|
31
|
+
raise "app invalid : #{app}" unless app.is_a? ::FluidFeatures::App
|
32
|
+
|
33
|
+
@app = app
|
34
|
+
|
35
|
+
@buckets = []
|
36
|
+
@buckets_lock = ::Mutex.new
|
37
|
+
|
38
|
+
@current_bucket = nil
|
39
|
+
@current_bucket_lock = ::Mutex.new
|
40
|
+
@current_bucket = new_bucket
|
41
|
+
|
42
|
+
@unknown_features = {}
|
43
|
+
@unknown_features_lock = ::Mutex.new
|
44
|
+
|
45
|
+
run_transcation_sender
|
46
|
+
|
47
|
+
end
|
48
|
+
|
49
|
+
# Pass FluidFeatures::AppTransaction for reporting back to the
|
50
|
+
# FluidFeatures service.
|
51
|
+
def report_transaction(transaction)
|
52
|
+
|
53
|
+
user = transaction.user
|
54
|
+
|
55
|
+
payload = {
|
56
|
+
:url => transaction.url,
|
57
|
+
:user => {
|
58
|
+
:id => user.unique_id
|
59
|
+
},
|
60
|
+
:hits => {
|
61
|
+
:feature => transaction.features_hit,
|
62
|
+
:goal => transaction.goals_hit
|
63
|
+
},
|
64
|
+
# stats
|
65
|
+
:stats => {
|
66
|
+
:duration => transaction.duration
|
67
|
+
}
|
68
|
+
}
|
69
|
+
|
70
|
+
payload_user = payload[:user] ||= {}
|
71
|
+
payload_user[:name] = user.display_name if user.display_name
|
72
|
+
payload_user[:anonymous] = user.anonymous if user.anonymous
|
73
|
+
payload_user[:unique] = user.unique_attrs if user.unique_attrs
|
74
|
+
payload_user[:cohorts] = user.cohort_attrs if user.cohort_attrs
|
75
|
+
|
76
|
+
queue_transaction_payload(payload)
|
77
|
+
|
78
|
+
if transaction.unknown_features.size > 0
|
79
|
+
queue_unknown_features(transaction.unknown_features)
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
|
84
|
+
def run_transcation_sender
|
85
|
+
Thread.new do
|
86
|
+
while true
|
87
|
+
begin
|
88
|
+
|
89
|
+
unless transactions_queued?
|
90
|
+
sleep WAIT_BETWEEN_QUEUE_EMTPY_CHECKS
|
91
|
+
next
|
92
|
+
end
|
93
|
+
|
94
|
+
success = send_transcations
|
95
|
+
|
96
|
+
if success
|
97
|
+
# Unless we have a full bucket waiting do not make
|
98
|
+
# more than N requests per second.
|
99
|
+
if bucket_count <= 1
|
100
|
+
sleep WAIT_BETWEEN_SEND_SUCCESS_NONE_WAITING
|
101
|
+
else
|
102
|
+
sleep WAIT_BETWEEN_SEND_SUCCESS_NEXT_WAITING
|
103
|
+
end
|
104
|
+
else
|
105
|
+
# If service is down, then slow our requests
|
106
|
+
# within this thread
|
107
|
+
sleep WAIT_BETWEEN_SEND_FAILURES
|
108
|
+
end
|
109
|
+
|
110
|
+
rescue Exception => err
|
111
|
+
# catch errors, so that we do not affect the rest of the application
|
112
|
+
app.logger.error "[FF] send_transcations failed : #{err.message}\n#{err.backtrace.join("\n")}"
|
113
|
+
# hold off for a little while and try again
|
114
|
+
sleep WAIT_BETWEEN_SEND_FAILURES
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
@private
|
121
|
+
def transactions_queued?
|
122
|
+
have_transactions = false
|
123
|
+
@buckets_lock.synchronize do
|
124
|
+
if @buckets.size == 1
|
125
|
+
@current_bucket_lock.synchronize do
|
126
|
+
if @current_bucket.size > 0
|
127
|
+
have_transactions = true
|
128
|
+
end
|
129
|
+
end
|
130
|
+
elsif @buckets.size > 1 and @buckets[0].size > 0
|
131
|
+
have_transactions = true
|
132
|
+
end
|
133
|
+
end
|
134
|
+
have_transactions
|
135
|
+
end
|
136
|
+
|
137
|
+
@private
|
138
|
+
def send_transcations
|
139
|
+
bucket = remove_bucket
|
140
|
+
|
141
|
+
ff_latency = app.client.last_fetch_duration
|
142
|
+
|
143
|
+
# Take existing unknown features and reset
|
144
|
+
unknown_features = nil
|
145
|
+
@unknown_features_lock.synchronize do
|
146
|
+
unknown_features = @unknown_features
|
147
|
+
@unknown_features = {}
|
148
|
+
end
|
149
|
+
|
150
|
+
remaining_buckets_stats = nil
|
151
|
+
@buckets_lock.synchronize do
|
152
|
+
remaining_buckets_stats = @buckets.map { |b| b.size }
|
153
|
+
end
|
154
|
+
|
155
|
+
api_request_log = app.client.siphon_api_request_log
|
156
|
+
|
157
|
+
payload = {
|
158
|
+
:client_uuid => app.client.uuid,
|
159
|
+
:transactions => bucket,
|
160
|
+
:stats => {
|
161
|
+
:ff_latency => ff_latency,
|
162
|
+
:waiting_buckets => remaining_buckets_stats
|
163
|
+
},
|
164
|
+
:unknown_features => unknown_features,
|
165
|
+
:api_request_log => api_request_log
|
166
|
+
}
|
167
|
+
|
168
|
+
if remaining_buckets_stats.size > 0
|
169
|
+
payload[:stats][:waiting_buckets] = remaining_buckets_stats
|
170
|
+
end
|
171
|
+
|
172
|
+
# attempt to send to fluidfeatures service
|
173
|
+
success = app.post("/report/transactions", payload)
|
174
|
+
|
175
|
+
# handle failure to send data
|
176
|
+
unless success
|
177
|
+
# return bucket into bucket queue until the next attempt at sending
|
178
|
+
if not unremove_bucket(bucket)
|
179
|
+
app.logger.warn "[FF] Discarded #{discarded_bucket.size} transactions due to reporter backlog. These will not be reported to FluidFeatures."
|
180
|
+
end
|
181
|
+
# return unknown features to queue until the next attempt at sending
|
182
|
+
queue_unknown_features(unknown_features)
|
183
|
+
end
|
184
|
+
|
185
|
+
# return whether we were able to send or not
|
186
|
+
success
|
187
|
+
end
|
188
|
+
|
189
|
+
@private
|
190
|
+
def bucket_count
|
191
|
+
num_buckets = 0
|
192
|
+
@buckets_lock.synchronize do
|
193
|
+
num_buckets = @buckets.size
|
194
|
+
end
|
195
|
+
num_buckets
|
196
|
+
end
|
197
|
+
|
198
|
+
@private
|
199
|
+
def new_bucket
|
200
|
+
bucket = []
|
201
|
+
discarded_bucket = nil
|
202
|
+
@buckets_lock.synchronize do
|
203
|
+
@buckets << bucket
|
204
|
+
if @buckets.size > MAX_BUCKETS
|
205
|
+
discarded_bucket = @buckets.shift
|
206
|
+
end
|
207
|
+
end
|
208
|
+
if discarded_bucket
|
209
|
+
app.logger.warn "[FF] Discarded #{discarded_bucket.size} transactions due to reporter backlog. These will not be reported to FluidFeatures."
|
210
|
+
end
|
211
|
+
bucket
|
212
|
+
end
|
213
|
+
|
214
|
+
@private
|
215
|
+
def remove_bucket
|
216
|
+
removed_bucket = nil
|
217
|
+
@buckets_lock.synchronize do
|
218
|
+
if @buckets.size > 0
|
219
|
+
removed_bucket = @buckets.shift
|
220
|
+
end
|
221
|
+
if @buckets.size == 0
|
222
|
+
@current_bucket_lock.synchronize do
|
223
|
+
@current_bucket = []
|
224
|
+
@buckets << @current_bucket
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|
228
|
+
removed_bucket
|
229
|
+
end
|
230
|
+
|
231
|
+
@private
|
232
|
+
def unremove_bucket(bucket)
|
233
|
+
success = false
|
234
|
+
@buckets_lock.synchronize do
|
235
|
+
if @buckets.size <= MAX_BUCKETS
|
236
|
+
@buckets.unshift bucket
|
237
|
+
success = true
|
238
|
+
end
|
239
|
+
end
|
240
|
+
success
|
241
|
+
end
|
242
|
+
|
243
|
+
@private
|
244
|
+
def queue_transaction_payload(transaction_payload)
|
245
|
+
@current_bucket_lock.synchronize do
|
246
|
+
if @current_bucket.size >= MAX_BUCKET_SIZE
|
247
|
+
@current_bucket = new_bucket
|
248
|
+
end
|
249
|
+
@current_bucket << transaction_payload
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
@private
|
254
|
+
def queue_unknown_features(unknown_features)
|
255
|
+
raise "unknown_features should be a Hash" unless unknown_features.is_a? Hash
|
256
|
+
unknown_features.each_pair do |feature_name, versions|
|
257
|
+
raise "unknown_features values should be Hash. versions=#{versions}" unless versions.is_a? Hash
|
258
|
+
end
|
259
|
+
@unknown_features_lock.synchronize do
|
260
|
+
unknown_features.each_pair do |feature_name, versions|
|
261
|
+
unless @unknown_features.has_key? feature_name
|
262
|
+
@unknown_features[feature_name] = {}
|
263
|
+
end
|
264
|
+
versions.each_pair do |version_name, default_enabled|
|
265
|
+
unless @unknown_features[feature_name].has_key? version_name
|
266
|
+
@unknown_features[feature_name][version_name] = default_enabled
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
end
|
274
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
|
2
|
+
require "digest/sha1"
|
3
|
+
require "set"
|
4
|
+
require "thread"
|
5
|
+
|
6
|
+
require "fluidfeatures/const"
|
7
|
+
require "fluidfeatures/app/transaction"
|
8
|
+
|
9
|
+
module FluidFeatures
|
10
|
+
class AppState
|
11
|
+
|
12
|
+
attr_accessor :app
|
13
|
+
USER_ID_NUMERIC = Regexp.compile("^\d+$")
|
14
|
+
|
15
|
+
# Request to FluidFeatures API to long-poll for max
|
16
|
+
# 30 seconds. The API may choose a different duration.
|
17
|
+
# If not change in this time, API will return HTTP 304.
|
18
|
+
ETAG_WAIT = ENV["FF_DEV"] ? 5 : 30
|
19
|
+
|
20
|
+
# Hard max of 2 req/sec
|
21
|
+
WAIT_BETWEEN_FETCH_SUCCESS = 0.5 # seconds
|
22
|
+
|
23
|
+
# Hard max of 10 req/sec
|
24
|
+
WAIT_BETWEEN_SEND_SUCCESS_NEXT_WAITING = 0.1 # seconds
|
25
|
+
|
26
|
+
# If we are failing to communicate with the FluidFeautres API
|
27
|
+
# then wait for this long between requests.
|
28
|
+
WAIT_BETWEEN_FETCH_FAILURES = 5 # seconds
|
29
|
+
|
30
|
+
def initialize(app)
|
31
|
+
|
32
|
+
raise "app invalid : #{app}" unless app.is_a? FluidFeatures::App
|
33
|
+
|
34
|
+
@app = app
|
35
|
+
@features = {}
|
36
|
+
@features_lock = ::Mutex.new
|
37
|
+
|
38
|
+
run_state_fetcher
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
def features
|
43
|
+
f = nil
|
44
|
+
@features_lock.synchronize do
|
45
|
+
f = @features
|
46
|
+
end
|
47
|
+
f
|
48
|
+
end
|
49
|
+
|
50
|
+
def features= f
|
51
|
+
return unless f.is_a? Hash
|
52
|
+
@features_lock.synchronize do
|
53
|
+
@features = f
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def run_state_fetcher
|
58
|
+
Thread.new do
|
59
|
+
while true
|
60
|
+
begin
|
61
|
+
|
62
|
+
success, state = load_state
|
63
|
+
|
64
|
+
# Note, success could be true, but state might be nil.
|
65
|
+
# This occurs with 304 (no change)
|
66
|
+
if success and state
|
67
|
+
# switch out current state with new one
|
68
|
+
self.features = state
|
69
|
+
elsif not success
|
70
|
+
# If service is down, then slow our requests
|
71
|
+
# within this thread
|
72
|
+
sleep WAIT_BETWEEN_FETCH_FAILURES
|
73
|
+
end
|
74
|
+
|
75
|
+
# What ever happens never make more than N requests
|
76
|
+
# per second
|
77
|
+
sleep WAIT_BETWEEN_FETCH_SUCCESS
|
78
|
+
|
79
|
+
rescue Exception => err
|
80
|
+
# catch errors, so that we do not affect the rest of the application
|
81
|
+
app.logger.error "load_state failed : #{err.message}\n#{err.backtrace.join("\n")}"
|
82
|
+
# hold off for a little while and try again
|
83
|
+
sleep WAIT_BETWEEN_FETCH_FAILURES
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def load_state
|
90
|
+
success, state = app.get("/features", { :verbose => true, :etag_wait => ETAG_WAIT }, true)
|
91
|
+
if success and state
|
92
|
+
state.each_pair do |feature_name, feature|
|
93
|
+
feature["versions"].each_pair do |version_name, version|
|
94
|
+
# convert parts to a Set for quick lookup
|
95
|
+
version["parts"] = Set.new(version["parts"] || [])
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
return success, state
|
100
|
+
end
|
101
|
+
|
102
|
+
def feature_version_enabled_for_user(feature_name, version_name, user_id, user_attributes={})
|
103
|
+
raise "feature_name invalid : #{feature_name}" unless feature_name.is_a? String
|
104
|
+
version_name ||= ::FluidFeatures::DEFAULT_VERSION_NAME
|
105
|
+
raise "version_name invalid : #{version_name}" unless version_name.is_a? String
|
106
|
+
|
107
|
+
#assert(isinstance(user_id, basestring))
|
108
|
+
|
109
|
+
user_attributes ||= {}
|
110
|
+
user_attributes["user"] = user_id.to_s
|
111
|
+
if user_id.is_a? Integer
|
112
|
+
user_id_hash = user_id
|
113
|
+
elsif USER_ID_NUMERIC.match(user_id)
|
114
|
+
user_id_hash = user_id.to_i
|
115
|
+
else
|
116
|
+
user_id_hash = Digest::SHA1.hexdigest(user_id)[-10, 10].to_i(16)
|
117
|
+
end
|
118
|
+
enabled = false
|
119
|
+
|
120
|
+
feature = features[feature_name]
|
121
|
+
version = feature["versions"][version_name]
|
122
|
+
modulus = user_id_hash % feature["num_parts"]
|
123
|
+
enabled = version["parts"].include? modulus
|
124
|
+
|
125
|
+
# check attributes
|
126
|
+
feature["versions"].each_pair do |other_version_name, other_version|
|
127
|
+
if other_version
|
128
|
+
version_attributes = (other_version["enabled"] || {})["attributes"]
|
129
|
+
if version_attributes
|
130
|
+
user_attributes.each_pair do |attr_key, attr_id|
|
131
|
+
version_attribute = version_attributes[attr_key.to_s]
|
132
|
+
if version_attribute and version_attribute.include? attr_id.to_s
|
133
|
+
if other_version_name == version_name
|
134
|
+
# explicitly enabled for this version
|
135
|
+
return true
|
136
|
+
else
|
137
|
+
# explicitly enabled for another version
|
138
|
+
return false
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
enabled
|
147
|
+
end
|
148
|
+
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
|
2
|
+
require "fluidfeatures/const"
|
3
|
+
|
4
|
+
module FluidFeatures
|
5
|
+
class AppUserTransaction
|
6
|
+
|
7
|
+
attr_accessor :user, :url, :features, :start_time, :ended, :features_hit, :goals_hit, :unknown_features
|
8
|
+
|
9
|
+
def initialize(user, url)
|
10
|
+
|
11
|
+
@user = user
|
12
|
+
@url = url
|
13
|
+
|
14
|
+
# take a snap-shot of the features end at
|
15
|
+
# the beginning of the transaction
|
16
|
+
@features = user.features
|
17
|
+
|
18
|
+
@features_hit = {}
|
19
|
+
@goals_hit = {}
|
20
|
+
@unknown_features = {}
|
21
|
+
@start_time = Time.now
|
22
|
+
@ended = false
|
23
|
+
|
24
|
+
end
|
25
|
+
|
26
|
+
def feature_enabled?(feature_name, version_name, default_enabled)
|
27
|
+
raise "transaction ended" if ended
|
28
|
+
raise "feature_name invalid : #{feature_name}" unless feature_name.is_a? String
|
29
|
+
version_name ||= ::FluidFeatures::DEFAULT_VERSION_NAME
|
30
|
+
|
31
|
+
if features.has_key? feature_name
|
32
|
+
feature = features[feature_name]
|
33
|
+
if feature.is_a? Hash
|
34
|
+
if feature.has_key? version_name
|
35
|
+
enabled = feature[version_name]
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
if enabled === nil
|
41
|
+
enabled = default_enabled
|
42
|
+
|
43
|
+
# Tell FluidFeatures about this amazing new feature...
|
44
|
+
unknown_feature_hit(feature_name, version_name, default_enabled)
|
45
|
+
end
|
46
|
+
|
47
|
+
if enabled
|
48
|
+
@features_hit[feature_name] ||= {}
|
49
|
+
@features_hit[feature_name][version_name.to_s] = {}
|
50
|
+
end
|
51
|
+
|
52
|
+
enabled
|
53
|
+
end
|
54
|
+
|
55
|
+
#
|
56
|
+
# This is called when we encounter a feature_name that
|
57
|
+
# FluidFeatures has no record of for your application.
|
58
|
+
# This will be reported back to the FluidFeatures service so
|
59
|
+
# that it can populate your dashboard with this feature.
|
60
|
+
# The parameter "default_enabled" is a boolean that says whether
|
61
|
+
# this feature should be enabled to all users or no users.
|
62
|
+
# Usually, this is "true" for existing features that you are
|
63
|
+
# planning to phase out and "false" for new feature that you
|
64
|
+
# intend to phase in.
|
65
|
+
#
|
66
|
+
def unknown_feature_hit(feature_name, version_name, default_enabled)
|
67
|
+
raise "transaction ended" if ended
|
68
|
+
unless @unknown_features.has_key? feature_name
|
69
|
+
@unknown_features[feature_name] = {}
|
70
|
+
end
|
71
|
+
unless @unknown_features[feature_name].has_key? version_name
|
72
|
+
@unknown_features[feature_name][version_name] = default_enabled
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def goal_hit(goal_name, goal_version_name)
|
77
|
+
raise "transaction ended" if ended
|
78
|
+
raise "goal_name invalid : #{goal_name}" unless goal_name.is_a? String
|
79
|
+
goal_version_name ||= ::FluidFeatures::DEFAULT_VERSION_NAME
|
80
|
+
raise "goal_version_name invalid : #{goal_version_name}" unless goal_version_name.is_a? String
|
81
|
+
@goals_hit[goal_name.to_s] ||= {}
|
82
|
+
@goals_hit[goal_name.to_s][goal_version_name.to_s] = {}
|
83
|
+
end
|
84
|
+
|
85
|
+
def duration
|
86
|
+
if ended
|
87
|
+
@duration
|
88
|
+
else
|
89
|
+
Time.now - start_time
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
#
|
94
|
+
# This reports back to FluidFeatures which features we
|
95
|
+
# encountered during this request, the request duration,
|
96
|
+
# and statistics on time spent talking to the FluidFeatures
|
97
|
+
# service. Any new features encountered will also be reported
|
98
|
+
# back with the default_enabled status (see unknown_feature_hit)
|
99
|
+
# so that FluidFeatures can auto-populate the dashboard.
|
100
|
+
#
|
101
|
+
def end_transaction
|
102
|
+
|
103
|
+
raise "transaction already ended" if ended
|
104
|
+
@ended = true
|
105
|
+
@duration = Time.now - start_time
|
106
|
+
user.app.reporter.report_transaction(self)
|
107
|
+
end
|
108
|
+
|
109
|
+
end
|
110
|
+
end
|
@@ -1,5 +1,6 @@
|
|
1
1
|
|
2
2
|
require "fluidfeatures/const"
|
3
|
+
require "fluidfeatures/app/transaction"
|
3
4
|
|
4
5
|
module FluidFeatures
|
5
6
|
class AppUser
|
@@ -8,7 +9,7 @@ module FluidFeatures
|
|
8
9
|
|
9
10
|
def initialize(app, user_id, display_name, is_anonymous, unique_attrs, cohort_attrs)
|
10
11
|
|
11
|
-
raise "app invalid : #{app}" unless app.is_a? FluidFeatures::App
|
12
|
+
raise "app invalid : #{app}" unless app.is_a? ::FluidFeatures::App
|
12
13
|
|
13
14
|
@app = app
|
14
15
|
@unique_id = user_id
|
@@ -17,11 +18,6 @@ module FluidFeatures
|
|
17
18
|
@unique_attrs = unique_attrs
|
18
19
|
@cohort_attrs = cohort_attrs
|
19
20
|
|
20
|
-
@features = nil
|
21
|
-
@features_hit = {}
|
22
|
-
@goals_hit = {}
|
23
|
-
@unknown_features = {}
|
24
|
-
|
25
21
|
if not unique_id or is_anonymous
|
26
22
|
|
27
23
|
# We're an anonymous user
|
@@ -37,12 +33,20 @@ module FluidFeatures
|
|
37
33
|
|
38
34
|
end
|
39
35
|
|
36
|
+
def get(path, params=nil)
|
37
|
+
app.get("/user/#{unique_id}#{path}", params)
|
38
|
+
end
|
39
|
+
|
40
|
+
def post(path, payload)
|
41
|
+
app.post("/user/#{unique_id}#{path}", payload)
|
42
|
+
end
|
43
|
+
|
40
44
|
#
|
41
45
|
# Returns all the features enabled for a specific user.
|
42
46
|
# This will depend on the user's unique_id and how many
|
43
47
|
# users each feature is enabled for.
|
44
48
|
#
|
45
|
-
def
|
49
|
+
def features
|
46
50
|
|
47
51
|
# extract just attribute ids into simple hash
|
48
52
|
attribute_ids = {
|
@@ -71,109 +75,28 @@ module FluidFeatures
|
|
71
75
|
end
|
72
76
|
end
|
73
77
|
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
if feature.is_a? Hash
|
89
|
-
if feature.has_key? version_name
|
90
|
-
enabled = feature[version_name]
|
78
|
+
if ENV["FLUIDFEATURES_USER_FEATURES_FROM_API"]
|
79
|
+
features_enabled = get("/features", attribute_ids) || {}
|
80
|
+
else
|
81
|
+
features_enabled = {}
|
82
|
+
app.state.features.each do |feature_name, feature|
|
83
|
+
feature["versions"].keys.each do |version_name|
|
84
|
+
features_enabled[feature_name] ||= {}
|
85
|
+
features_enabled[feature_name][version_name] = \
|
86
|
+
app.state.feature_version_enabled_for_user(
|
87
|
+
feature_name,
|
88
|
+
version_name,
|
89
|
+
unique_id,
|
90
|
+
attribute_ids
|
91
|
+
)
|
91
92
|
end
|
92
93
|
end
|
93
94
|
end
|
94
|
-
|
95
|
-
if enabled === nil
|
96
|
-
enabled = default_enabled
|
97
|
-
|
98
|
-
# Tell FluidFeatures about this amazing new feature...
|
99
|
-
unknown_feature_hit(feature_name, version_name, default_enabled)
|
100
|
-
end
|
101
|
-
|
102
|
-
if enabled
|
103
|
-
@features_hit[feature_name] ||= {}
|
104
|
-
@features_hit[feature_name][version_name.to_s] = {}
|
105
|
-
end
|
106
|
-
|
107
|
-
enabled
|
108
|
-
end
|
109
|
-
|
110
|
-
#
|
111
|
-
# This is called when we encounter a feature_name that
|
112
|
-
# FluidFeatures has no record of for your application.
|
113
|
-
# This will be reported back to the FluidFeatures service so
|
114
|
-
# that it can populate your dashboard with this feature.
|
115
|
-
# The parameter "default_enabled" is a boolean that says whether
|
116
|
-
# this feature should be enabled to all users or no users.
|
117
|
-
# Usually, this is "true" for existing features that you are
|
118
|
-
# planning to phase out and "false" for new feature that you
|
119
|
-
# intend to phase in.
|
120
|
-
#
|
121
|
-
def unknown_feature_hit(feature_name, version_name, default_enabled)
|
122
|
-
if not @unknown_features[feature_name]
|
123
|
-
@unknown_features[feature_name] = { :versions => {} }
|
124
|
-
end
|
125
|
-
@unknown_features[feature_name][:versions][version_name] = default_enabled
|
126
|
-
end
|
127
|
-
|
128
|
-
def goal_hit(goal_name, goal_version_name)
|
129
|
-
sleep 10
|
130
|
-
raise "goal_name invalid : #{goal_name}" unless goal_name.is_a? String
|
131
|
-
goal_version_name ||= ::FluidFeatures::DEFAULT_VERSION_NAME
|
132
|
-
raise "goal_version_name invalid : #{goal_version_name}" unless goal_version_name.is_a? String
|
133
|
-
@goals_hit[goal_name.to_s] ||= {}
|
134
|
-
@goals_hit[goal_name.to_s][goal_version_name.to_s] = {}
|
95
|
+
features_enabled
|
135
96
|
end
|
136
97
|
|
137
|
-
|
138
|
-
|
139
|
-
# encountered during this request, the request duration,
|
140
|
-
# and statistics on time spent talking to the FluidFeatures
|
141
|
-
# service. Any new features encountered will also be reported
|
142
|
-
# back with the default_enabled status (see unknown_feature_hit)
|
143
|
-
# so that FluidFeatures can auto-populate the dashboard.
|
144
|
-
#
|
145
|
-
def end_transaction(url, stats)
|
146
|
-
|
147
|
-
payload = {
|
148
|
-
:url => url,
|
149
|
-
:user => {
|
150
|
-
:id => unique_id
|
151
|
-
},
|
152
|
-
:hits => {
|
153
|
-
:feature => @features_hit,
|
154
|
-
:goal => @goals_hit
|
155
|
-
}
|
156
|
-
}
|
157
|
-
|
158
|
-
if stats
|
159
|
-
raise "stats invalid : #{stats}" unless stats.is_a? Hash
|
160
|
-
payload[:stats] = stats
|
161
|
-
end
|
162
|
-
|
163
|
-
payload_user = payload[:user] ||= {}
|
164
|
-
payload_user[:name] = display_name if display_name
|
165
|
-
payload_user[:anonymous] = anonymous if anonymous
|
166
|
-
payload_user[:unique] = unique_attrs if unique_attrs
|
167
|
-
payload_user[:cohorts] = cohort_attrs if cohort_attrs
|
168
|
-
|
169
|
-
(payload[:stats] ||= {})[:ff_latency] = app.client.last_fetch_duration
|
170
|
-
if @unknown_features.size
|
171
|
-
(payload[:features] ||= {})[:unknown] = @unknown_features
|
172
|
-
@unknown_features = {}
|
173
|
-
end
|
174
|
-
|
175
|
-
app.post("/user/#{unique_id}/features/hit", payload)
|
176
|
-
|
98
|
+
def transaction(url)
|
99
|
+
::FluidFeatures::AppUserTransaction.new(self, url)
|
177
100
|
end
|
178
101
|
|
179
102
|
end
|
data/lib/fluidfeatures/app.rb
CHANGED
@@ -1,11 +1,13 @@
|
|
1
1
|
|
2
2
|
require "fluidfeatures/app/user"
|
3
3
|
require "fluidfeatures/app/feature"
|
4
|
+
require "fluidfeatures/app/state"
|
5
|
+
require "fluidfeatures/app/reporter"
|
4
6
|
|
5
7
|
module FluidFeatures
|
6
8
|
class App
|
7
9
|
|
8
|
-
attr_accessor :client, :app_id, :secret, :logger
|
10
|
+
attr_accessor :client, :app_id, :secret, :state, :reporter, :logger
|
9
11
|
|
10
12
|
def initialize(client, app_id, secret, logger)
|
11
13
|
|
@@ -17,11 +19,13 @@ module FluidFeatures
|
|
17
19
|
@app_id = app_id
|
18
20
|
@secret = secret
|
19
21
|
@logger = logger
|
22
|
+
@state = ::FluidFeatures::AppState.new(self)
|
23
|
+
@reporter = ::FluidFeatures::AppReporter.new(self)
|
20
24
|
|
21
25
|
end
|
22
26
|
|
23
|
-
def get(path, params=nil)
|
24
|
-
client.get("/app/#{app_id}#{path}", secret, params)
|
27
|
+
def get(path, params=nil, cache=false)
|
28
|
+
client.get("/app/#{app_id}#{path}", secret, params, cache)
|
25
29
|
end
|
26
30
|
|
27
31
|
def put(path, payload)
|
@@ -44,6 +48,10 @@ module FluidFeatures
|
|
44
48
|
def user(user_id, display_name, is_anonymous, unique_attrs, cohort_attrs)
|
45
49
|
::FluidFeatures::AppUser.new(self, user_id, display_name, is_anonymous, unique_attrs, cohort_attrs)
|
46
50
|
end
|
51
|
+
|
52
|
+
def user_transaction(user_id, url, display_name, is_anonymous, unique_attrs, cohort_attrs)
|
53
|
+
user(user_id, display_name, is_anonymous, unique_attrs, cohort_attrs).transaction(url)
|
54
|
+
end
|
47
55
|
|
48
56
|
def feature_version(feature_name, version_name)
|
49
57
|
::FluidFeatures::AppFeatureVersion.new(self, feature_name, version_name)
|
data/lib/fluidfeatures/client.rb
CHANGED
@@ -1,17 +1,22 @@
|
|
1
1
|
|
2
|
-
require
|
3
|
-
require
|
2
|
+
require "net/http"
|
3
|
+
require "persistent_http"
|
4
4
|
require "pre_ruby192/uri" if RUBY_VERSION < "1.9.2"
|
5
|
+
require "thread"
|
6
|
+
require "uuid"
|
5
7
|
|
6
8
|
require "fluidfeatures/app"
|
7
9
|
|
8
10
|
module FluidFeatures
|
9
11
|
class Client
|
10
12
|
|
11
|
-
attr_accessor :base_uri, :logger, :last_fetch_duration
|
13
|
+
attr_accessor :uuid, :base_uri, :logger, :last_fetch_duration
|
14
|
+
|
15
|
+
API_REQUEST_LOG_MAX_SIZE = 200
|
12
16
|
|
13
17
|
def initialize(base_uri, logger)
|
14
18
|
|
19
|
+
@uuid = UUID.new.generate
|
15
20
|
@logger = logger
|
16
21
|
@base_uri = base_uri
|
17
22
|
|
@@ -24,11 +29,41 @@ module FluidFeatures
|
|
24
29
|
:url => base_uri
|
25
30
|
)
|
26
31
|
|
27
|
-
@
|
32
|
+
@api_request_log = []
|
33
|
+
@api_request_log_lock = ::Mutex.new
|
34
|
+
|
35
|
+
@etags = {}
|
36
|
+
@etags_lock = ::Mutex.new
|
37
|
+
|
38
|
+
end
|
39
|
+
|
40
|
+
def log_api_request(method, url, duration, status_code, err_msg)
|
41
|
+
@api_request_log_lock.synchronize do
|
42
|
+
@api_request_log << {
|
43
|
+
:method => method,
|
44
|
+
:url => url,
|
45
|
+
:duration => duration,
|
46
|
+
:status => status_code,
|
47
|
+
:err => err_msg,
|
48
|
+
:time => Time.now.to_f.round(2)
|
49
|
+
}
|
50
|
+
# remove older entry if too big
|
51
|
+
if @api_request_log.size > API_REQUEST_LOG_MAX_SIZE
|
52
|
+
@api_request_log.shift
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
28
56
|
|
57
|
+
def siphon_api_request_log
|
58
|
+
request_log = nil
|
59
|
+
@api_request_log_lock.synchronize do
|
60
|
+
request_log = @api_request_log
|
61
|
+
@api_request_log = []
|
62
|
+
end
|
63
|
+
request_log
|
29
64
|
end
|
30
65
|
|
31
|
-
def get(path, auth_token, url_params=nil)
|
66
|
+
def get(path, auth_token, url_params=nil, cache=false)
|
32
67
|
payload = nil
|
33
68
|
|
34
69
|
uri = URI(@base_uri + path)
|
@@ -40,60 +75,144 @@ module FluidFeatures
|
|
40
75
|
end
|
41
76
|
end
|
42
77
|
|
78
|
+
duration = nil
|
79
|
+
status_code = nil
|
80
|
+
err_msg = nil
|
81
|
+
no_change = false
|
82
|
+
success = false
|
43
83
|
begin
|
84
|
+
|
44
85
|
request = Net::HTTP::Get.new url_path
|
45
86
|
request["Accept"] = "application/json"
|
46
87
|
request['AUTHORIZATION'] = auth_token
|
47
|
-
|
88
|
+
@etags_lock.synchronize do
|
89
|
+
if cache and @etags.has_key? url_path
|
90
|
+
request["If-None-Match"] = @etags[url_path][:etag]
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
request_start_time = Time.now
|
48
95
|
response = @http.request request
|
49
|
-
|
50
|
-
|
51
|
-
|
96
|
+
duration = Time.now - request_start_time
|
97
|
+
|
98
|
+
if response.is_a? Net::HTTPResponse
|
99
|
+
status_code = response.code
|
100
|
+
if response.is_a? Net::HTTPNotModified
|
101
|
+
no_change = true
|
102
|
+
success = true
|
103
|
+
elsif response.is_a? Net::HTTPSuccess
|
104
|
+
payload = JSON.load(response.body) rescue nil
|
105
|
+
if cache
|
106
|
+
@etags_lock.synchronize do
|
107
|
+
@etags[url_path] = {
|
108
|
+
:etag => response["Etag"],
|
109
|
+
:time => Time.now
|
110
|
+
}
|
111
|
+
end
|
112
|
+
end
|
113
|
+
success = true
|
114
|
+
else
|
115
|
+
payload = JSON.load(response.body) rescue nil
|
116
|
+
if payload and payload.is_a? Hash and payload.has_key? "error"
|
117
|
+
err_msg = payload["error"]
|
118
|
+
end
|
119
|
+
logger.error{"[FF] Request unsuccessful for GET #{path} : #{response.class} #{status_code} #{err_msg}"}
|
120
|
+
end
|
52
121
|
end
|
122
|
+
rescue PersistentHTTP::Error => err
|
123
|
+
logger.error{"[FF] Request failed for GET #{path} : #{err.message}"}
|
53
124
|
rescue
|
54
|
-
logger.error{"[FF] Request failed
|
125
|
+
logger.error{"[FF] Request failed for GET #{path} : #{status_code} #{err_msg}"}
|
55
126
|
raise
|
127
|
+
else
|
128
|
+
unless no_change or payload
|
129
|
+
logger.error{"[FF] Empty response for GET #{path} : #{status_code} #{err_msg}"}
|
130
|
+
end
|
56
131
|
end
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
payload
|
132
|
+
|
133
|
+
log_api_request("GET", url_path, duration, status_code, err_msg)
|
134
|
+
|
135
|
+
return success, payload
|
61
136
|
end
|
62
137
|
|
63
138
|
def put(path, auth_token, payload)
|
139
|
+
uri = URI(@base_uri + path)
|
140
|
+
url_path = uri.path
|
141
|
+
duration = nil
|
142
|
+
status_code = nil
|
143
|
+
err_msg = nil
|
144
|
+
success = false
|
64
145
|
begin
|
65
|
-
|
66
|
-
request = Net::HTTP::Put.new uri.path
|
146
|
+
request = Net::HTTP::Put.new uri_path
|
67
147
|
request["Content-Type"] = "application/json"
|
68
148
|
request["Accept"] = "application/json"
|
69
149
|
request['AUTHORIZATION'] = auth_token
|
70
150
|
request.body = JSON.dump(payload)
|
151
|
+
|
152
|
+
request_start_time = Time.now
|
71
153
|
response = @http.request uri, request
|
72
|
-
|
73
|
-
|
154
|
+
duration = Time.now - request_start_time
|
155
|
+
|
156
|
+
raise "expected Net::HTTPResponse" if not response.is_a? Net::HTTPResponse
|
157
|
+
status_code = response.code
|
158
|
+
if response.is_a? Net::HTTPSuccess
|
159
|
+
success = true
|
160
|
+
else
|
161
|
+
response_payload = JSON.load(response.body) rescue nil
|
162
|
+
if response_payload.is_a? Hash and response_payload.has_key? "error"
|
163
|
+
err_msg = response_payload["error"]
|
164
|
+
end
|
165
|
+
logger.error{"[FF] Request unsuccessful for PUT #{path} : #{status_code} #{err_msg}"}
|
74
166
|
end
|
167
|
+
rescue PersistentHTTP::Error => err
|
168
|
+
logger.error{"[FF] Request failed for PUT #{path} : #{err.message}"}
|
75
169
|
rescue Exception => err
|
76
|
-
logger.error{"[FF] Request failed
|
170
|
+
logger.error{"[FF] Request failed for PUT #{path} : #{err.message}"}
|
77
171
|
raise
|
78
172
|
end
|
173
|
+
|
174
|
+
log_api_request("PUT", url_path, duration, status_code, err_msg)
|
175
|
+
return success
|
79
176
|
end
|
80
177
|
|
81
178
|
def post(path, auth_token, payload)
|
179
|
+
uri = URI(@base_uri + path)
|
180
|
+
url_path = uri.path
|
181
|
+
duration = nil
|
182
|
+
status_code = nil
|
183
|
+
err_msg = nil
|
184
|
+
success = false
|
82
185
|
begin
|
83
|
-
|
84
|
-
request = Net::HTTP::Post.new uri.path
|
186
|
+
request = Net::HTTP::Post.new url_path
|
85
187
|
request["Content-Type"] = "application/json"
|
86
188
|
request["Accept"] = "application/json"
|
87
189
|
request['AUTHORIZATION'] = auth_token
|
88
190
|
request.body = JSON.dump(payload)
|
191
|
+
|
192
|
+
request_start_time = Time.now
|
89
193
|
response = @http.request request
|
90
|
-
|
91
|
-
|
194
|
+
duration = Time.now - request_start_time
|
195
|
+
|
196
|
+
raise "expected Net::HTTPResponse" if not response.is_a? Net::HTTPResponse
|
197
|
+
status_code = response.code
|
198
|
+
if response.is_a? Net::HTTPSuccess
|
199
|
+
success = true
|
200
|
+
else
|
201
|
+
response_payload = JSON.load(response.body) rescue nil
|
202
|
+
if response_payload.is_a? Hash and response_payload.has_key? "error"
|
203
|
+
err_msg = response_payload["error"]
|
204
|
+
end
|
205
|
+
logger.error{"[FF] Request unsuccessful for POST #{path} : #{status_code} #{err_msg}"}
|
92
206
|
end
|
207
|
+
rescue PersistentHTTP::Error => err
|
208
|
+
logger.error{"[FF] Request failed for POST #{path} : #{err.message}"}
|
93
209
|
rescue Exception => err
|
94
|
-
logger.error{"[FF] Request failed
|
210
|
+
logger.error{"[FF] Request failed for POST #{path} : #{err.message}"}
|
95
211
|
raise
|
96
212
|
end
|
213
|
+
|
214
|
+
log_api_request("POST", url_path, duration, status_code, err_msg)
|
215
|
+
return success
|
97
216
|
end
|
98
217
|
|
99
218
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: fluidfeatures
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.3.
|
4
|
+
version: 0.3.4
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2012-
|
12
|
+
date: 2012-11-04 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: persistent_http
|
@@ -27,6 +27,22 @@ dependencies:
|
|
27
27
|
- - ~>
|
28
28
|
- !ruby/object:Gem::Version
|
29
29
|
version: 1.0.3
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: uuid
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ~>
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: 2.3.5
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ~>
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: 2.3.5
|
30
46
|
description: Ruby client for the FluidFeatures service.
|
31
47
|
email:
|
32
48
|
- phil@fluidfeatures.com
|
@@ -36,11 +52,15 @@ extra_rdoc_files: []
|
|
36
52
|
files:
|
37
53
|
- .gitignore
|
38
54
|
- Gemfile
|
55
|
+
- LICENSE
|
39
56
|
- README.md
|
40
57
|
- fluidfeatures.gemspec
|
41
58
|
- lib/fluidfeatures.rb
|
42
59
|
- lib/fluidfeatures/app.rb
|
43
60
|
- lib/fluidfeatures/app/feature.rb
|
61
|
+
- lib/fluidfeatures/app/reporter.rb
|
62
|
+
- lib/fluidfeatures/app/state.rb
|
63
|
+
- lib/fluidfeatures/app/transaction.rb
|
44
64
|
- lib/fluidfeatures/app/user.rb
|
45
65
|
- lib/fluidfeatures/client.rb
|
46
66
|
- lib/fluidfeatures/const.rb
|
@@ -66,7 +86,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
86
|
version: '0'
|
67
87
|
requirements: []
|
68
88
|
rubyforge_project: fluidfeatures
|
69
|
-
rubygems_version: 1.8.
|
89
|
+
rubygems_version: 1.8.23
|
70
90
|
signing_key:
|
71
91
|
specification_version: 3
|
72
92
|
summary: Ruby client for the FluidFeatures service.
|