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 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
+
@@ -14,4 +14,5 @@ Gem::Specification.new do |s|
14
14
  s.files = `git ls-files`.split("\n")
15
15
  s.require_paths = ["lib"]
16
16
  s.add_dependency "persistent_http", "~>1.0.3"
17
+ s.add_dependency "uuid", "~>2.3.5"
17
18
  end
@@ -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 load_features
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
- app.get("/user/#{unique_id}/features", attribute_ids) || {}
75
- end
76
-
77
- def features
78
- @features ||= load_features
79
- end
80
-
81
- def feature_enabled?(feature_name, version_name, default_enabled)
82
-
83
- raise "feature_name invalid : #{feature_name}" unless feature_name.is_a? String
84
- version_name ||= ::FluidFeatures::DEFAULT_VERSION_NAME
85
-
86
- if features.has_key? feature_name
87
- feature = features[feature_name]
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
- # This reports back to FluidFeatures which features we
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
@@ -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)
@@ -1,17 +1,22 @@
1
1
 
2
- require 'net/http'
3
- require 'persistent_http'
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
- @last_fetch_duration = nil
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
- fetch_start_time = Time.now
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
- if response.is_a?(Net::HTTPSuccess)
50
- payload = JSON.parse(response.body)
51
- @last_fetch_duration = Time.now - fetch_start_time
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 when getting #{path}"}
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
- if not payload
58
- logger.error{"[FF] Empty response from #{path}"}
59
- end
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
- uri = URI(@base_uri + path)
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
- unless response.is_a?(Net::HTTPSuccess)
73
- logger.error{"[FF] Request unsuccessful when putting #{path}"}
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 putting #{path} : #{err.message}"}
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
- uri = URI(@base_uri + path)
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
- unless response.is_a?(Net::HTTPSuccess)
91
- logger.error{"[FF] Request unsuccessful when posting #{path}"}
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 posting #{path} : #{err.message}"}
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
@@ -1,3 +1,3 @@
1
1
  module FluidFeatures
2
- VERSION = '0.3.1'
2
+ VERSION = '0.3.4'
3
3
  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.1
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-10-31 00:00:00.000000000 Z
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.24
89
+ rubygems_version: 1.8.23
70
90
  signing_key:
71
91
  specification_version: 3
72
92
  summary: Ruby client for the FluidFeatures service.