fluidfeatures 0.3.1 → 0.3.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/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.
|