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 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.