blackbeard 0.0.3.1 → 0.0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -20
  3. data/dashboard/public/stylesheets/application.css +4 -0
  4. data/dashboard/routes/features.rb +31 -0
  5. data/dashboard/routes/groups.rb +2 -2
  6. data/dashboard/routes/metrics.rb +6 -6
  7. data/dashboard/routes/tests.rb +2 -2
  8. data/dashboard/views/features/index.erb +21 -0
  9. data/dashboard/views/features/show.erb +163 -0
  10. data/dashboard/views/groups/show.erb +1 -1
  11. data/dashboard/views/layout.erb +2 -1
  12. data/lib/blackbeard.rb +18 -7
  13. data/lib/blackbeard/configuration.rb +3 -2
  14. data/lib/blackbeard/context.rb +11 -7
  15. data/lib/blackbeard/dashboard.rb +2 -0
  16. data/lib/blackbeard/errors.rb +4 -0
  17. data/lib/blackbeard/feature.rb +45 -0
  18. data/lib/blackbeard/feature_rollout.rb +47 -0
  19. data/lib/blackbeard/metric.rb +22 -4
  20. data/lib/blackbeard/pirate.rb +11 -5
  21. data/lib/blackbeard/redis_store.rb +4 -0
  22. data/lib/blackbeard/storable.rb +72 -6
  23. data/lib/blackbeard/storable_attributes.rb +50 -3
  24. data/lib/blackbeard/storable_has_many.rb +1 -0
  25. data/lib/blackbeard/storable_has_set.rb +1 -0
  26. data/lib/blackbeard/version.rb +1 -1
  27. data/spec/blackbeard_spec.rb +13 -0
  28. data/spec/configuration_spec.rb +6 -0
  29. data/spec/context_spec.rb +12 -12
  30. data/spec/dashboard/features_spec.rb +52 -0
  31. data/spec/dashboard/groups_spec.rb +4 -4
  32. data/spec/dashboard/metrics_spec.rb +6 -6
  33. data/spec/dashboard/tests_spec.rb +4 -4
  34. data/spec/feature_rollout_spec.rb +147 -0
  35. data/spec/feature_spec.rb +58 -0
  36. data/spec/group_spec.rb +1 -1
  37. data/spec/metric_data/total_spec.rb +1 -1
  38. data/spec/metric_data/unique_spec.rb +1 -1
  39. data/spec/metric_spec.rb +5 -5
  40. data/spec/pirate_spec.rb +16 -12
  41. data/spec/redis_store_spec.rb +6 -0
  42. data/spec/spec_helper.rb +3 -1
  43. data/spec/storable_attributes_spec.rb +58 -6
  44. data/spec/storable_has_many_spec.rb +2 -2
  45. data/spec/storable_has_set_spec.rb +1 -1
  46. data/spec/storable_spec.rb +119 -23
  47. data/spec/test_spec.rb +1 -1
  48. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: be704d408f74d3fed4eceb91f873c087967ed947
4
- data.tar.gz: 33e1a39fe2efeea751f99b9e769e00c72b19e99e
3
+ metadata.gz: a8e907aad46889da2e0abfab0cd85fdc87c93a4e
4
+ data.tar.gz: 125429d019e4e2e4767f18e2428faac8db1bff8b
5
5
  SHA512:
6
- metadata.gz: 33d585c054f11b46334cfdbd98907d0b659decd5b4df3d06172b6c44c365ad97500461be00f7d10324ca9499f7620786271a5b22745fca72f39fb6758dbd55f5
7
- data.tar.gz: d932724e9091f21dd1c16557cd821ac9a3c7b380391b362d0582a1cf80166381fae3cb07816dbe15f5e2fb5cff8169ede3087ae5b8d0ce9d8568dd5a56ea0271
6
+ metadata.gz: 94047af0590aa7109951b25870f1c46f1b12d9231c09af49f141c025c03dcca9468a1b48d75878e1154ed867cc9dc768175011af0d9d42725251757976478527
7
+ data.tar.gz: 7c407ffcffb34c88a29552e85a44a488bf377a7b21bc3f89f3d8d5c7213e3b888fcc5dad59ad8e1293e07a88b088694a824970c8f74a004eb82f5d2660c7631b
data/README.md CHANGED
@@ -135,20 +135,20 @@ $pirate.add_total(:revenue, +119.95) # can also accept floats
135
135
 
136
136
  ### Chaining Metrics
137
137
 
138
- Context methods that inrement metrics always return the context, so you can chain them together.
138
+ Context methods that increment metrics always return the context, so you can chain them together.
139
139
 
140
140
  ```ruby
141
141
  $pirate.set_context(user)
142
142
  $pirate.add_total(:like, +1).add_unique(:likers)
143
-
144
143
  $pirate.context(user).add_total(:like, +1).add_unique(:likers)
145
144
  ```
146
145
 
147
146
 
148
- ### Defining Tests (changes or features)
147
+ ### Defining AB Tests
149
148
 
150
- Features are defined in your views, controller or anywhere in your app via the global $pirate. There is no configuration necessary (but see the gotcha below).
149
+ AB tests are defined in your views, controller or anywhere in your app via the global $pirate. There is no configuration necessary (but see the gotcha below).
151
150
 
151
+ **_Note that ab_tests are implmented, but experiments to control them is not. AB tests and experiments are in active development._**
152
152
 
153
153
  In a view:
154
154
 
@@ -162,7 +162,7 @@ In a controller:
162
162
  @onboarding_path = $pirate.ab_test(:new_onboarding, :control => '/onboarding', :welcome_flow => '/welcome') %>
163
163
  ```
164
164
 
165
- You can call the feature multiple times with different variations:
165
+ You can call the test multiple times with different variations:
166
166
 
167
167
  ```ruby
168
168
  @button_bg_color = $pirate.ab_test(:button_color, :control => "#FFF", :black => "#000")
@@ -183,13 +183,7 @@ if $pirate.ab_test(:join_form) == :long_version do
183
183
  end
184
184
  ```
185
185
 
186
- If you're simply rolling out a feature or want a feature flipper, you can:
187
-
188
- ```ruby
189
- if $pirate.active?(:friend_feed){ ... } # shorthand for test(:friend_feed) == :active
190
- ```
191
-
192
- GOTCHA #1: It's good to avoid elsif and case statements when testing for features. Blackbeard learns about the features and their variations dynamically. If you're not passing in your variations as a hash, but only using conditionals, you can ensure all your variations are available with:
186
+ GOTCHA #1: It's good to avoid elsif and case statements. Blackbeard learns about the tests and their variations dynamically. If you're not passing in your variations as a hash, but only using conditionals, you can ensure all your variations are available with:
193
187
 
194
188
  ```ruby
195
189
  $pirate.test(:friend_feed).add_variations(:variation_one, :variation_two, ...)
@@ -197,14 +191,26 @@ $pirate.test(:friend_feed).add_variations(:variation_one, :variation_two, ...)
197
191
 
198
192
  Look at the dashboard to see which variations are registered.
199
193
 
200
- Features do not turn on automatically. When you first deploy the feature will be set to `:inactive` or `:control`, `:off`, or `:default` if any of those variations are defined.
194
+ Test do not turn on automatically. When you first deploy the test will be set to `:inactive` or `:control`, `:off`, or `:default` if any of those variations are defined.
201
195
 
202
196
  GOTCHA #2: If you do not define the :inactive, :control, :off or :default variations, the result will be nil. This is the desired behavior but it may be confusing.
203
197
 
204
198
  ```ruby
205
199
  $pirate.ab_test(:new_onboarding, :one => 'one', :two => 'two') # is the same as the next line
206
- $pirate.ab_test(:new_onboarding, :inactive => nil, :one => 'one', :two => 'two') # nil when feature is inactive
207
- $pirate.ab_test(:new_onboarding, :default => 'one', :two => 'two') # => 'one' when feature is inactive
200
+ $pirate.ab_test(:new_onboarding, :inactive => nil, :one => 'one', :two => 'two') # nil when test is inactive
201
+ $pirate.ab_test(:new_onboarding, :default => 'one', :two => 'two') # => 'one' when test is inactive
202
+ ```
203
+
204
+ ### Features
205
+
206
+ Features are very similar to AB Tests but they are only active or inactive. You can control how they are rolled out to groups and even individuals in the dashboard.
207
+
208
+ ```ruby
209
+ # With the context set
210
+ if $pirate.feature_active?(:friend_feed){ ... }
211
+
212
+ # Or outside a web request
213
+ if $pirate.context(user).feature_active?(:friend_feed){ ... }
208
214
  ```
209
215
 
210
216
  ### Defining groups
@@ -214,10 +220,6 @@ $pirate.define_group(:admin) do |user, context|
214
220
  user.admin? # true, false
215
221
  end
216
222
 
217
- $pirate.define_group(:medalist) do |user, context|
218
- user.engagement_level # nil, :bronze, :silver, :gold
219
- end
220
-
221
223
  $pirate.define_group(:seo_traffic) do |user, context|
222
224
  context.session.refer =~ /google.com/ # remember to store refer in sessions
223
225
  end
@@ -231,6 +233,16 @@ $pirate.define_group(:purchasers) do |user, context|
231
233
  end
232
234
  ```
233
235
 
236
+ If your group is segments, include a list possible segments.
237
+
238
+ ```ruby
239
+ $pirate.define_group(:medalist, [:bronze, :silver, :gold]) do |user, context|
240
+ user.engagement_level # nil, :bronze, :silver, :gold
241
+ end
242
+ ```
243
+
244
+ If your group definition block returns an uninitialized segment, it wil be initialized automatically.
245
+
234
246
  ## Contributing
235
247
 
236
248
  1. Fork it
@@ -238,4 +250,3 @@ end
238
250
  3. Commit your changes (`git commit -am 'Add some feature'`)
239
251
  4. Push to the branch (`git push origin my-new-feature`)
240
252
  5. Create new Pull Request
241
-
@@ -26,3 +26,7 @@ body {
26
26
  .editable-click:hover {
27
27
  background-color: #EEE;
28
28
  }
29
+
30
+ .editable-empty {
31
+ color: #999;
32
+ }
@@ -0,0 +1,31 @@
1
+ module Blackbeard
2
+ module DashboardRoutes
3
+ class Features < Base
4
+
5
+ get '/features' do
6
+ @features = Feature.all
7
+ erb 'features/index'.to_sym
8
+ end
9
+
10
+ get "/features/:id" do
11
+ @feature = Feature.find(params[:id]) or pass
12
+ @groups = Group.all
13
+ erb 'features/show'.to_sym
14
+ end
15
+
16
+ post "/features/:id" do
17
+ @feature = Feature.find(params[:id]) or pass
18
+ @feature.update_attributes(params)
19
+ "OK"
20
+ end
21
+
22
+ post "/features/:id/groups/:group_id" do
23
+ @feature = Feature.find(params[:id]) or pass
24
+ @feature.set_segments_for(params[:group_id], params[:segments])
25
+ @feature.save
26
+ "OK"
27
+ end
28
+
29
+ end
30
+ end
31
+ end
@@ -7,12 +7,12 @@ module Blackbeard
7
7
  end
8
8
 
9
9
  get '/groups/:id' do
10
- @group = Group.new(params[:id])
10
+ @group = Group.find(params[:id]) or pass
11
11
  erb 'groups/show'.to_sym
12
12
  end
13
13
 
14
14
  post "/groups/:id" do
15
- @group = Group.new(params[:id])
15
+ @group = Group.find(params[:id]) or pass
16
16
  @group.update_attributes(params)
17
17
  "OK"
18
18
  end
@@ -7,21 +7,21 @@ module Blackbeard
7
7
  end
8
8
 
9
9
  get "/metrics/:type/:type_id" do
10
- @metric = Metric.new(params[:type], params[:type_id])
11
- @group = Group.new(params[:group_id]) if params[:group_id]
10
+ @metric = Metric.find(params[:type], params[:type_id]) or pass
11
+ @group = Group.find(params[:group_id]) if params[:group_id]
12
12
  erb 'metrics/show'.to_sym
13
13
  end
14
14
 
15
15
  post "/metrics/:type/:type_id" do
16
- @metric = Metric.new(params[:type], params[:type_id])
16
+ @metric = Metric.find(params[:type], params[:type_id]) or pass
17
17
  @metric.update_attributes(params)
18
18
  "OK"
19
19
  end
20
20
 
21
21
  post "/metrics/:type/:type_id/groups" do
22
- @metric = Metric.new(params[:type], params[:type_id])
23
- @group = Group.new(params[:group_id])
24
- @metric.add_group(@group)
22
+ @metric = Metric.find(params[:type], params[:type_id]) or pass
23
+ @group = Group.find(params[:group_id])
24
+ @metric.add_group(@group) if @group
25
25
  redirect url("metrics/#{@metric.type}/#{@metric.type_id}?group_id=#{@group.id}")
26
26
  end
27
27
 
@@ -8,12 +8,12 @@ module Blackbeard
8
8
  end
9
9
 
10
10
  get "/tests/:id" do
11
- @test = Test.new(params[:id])
11
+ @test = Test.find(params[:id]) or pass
12
12
  erb 'tests/show'.to_sym
13
13
  end
14
14
 
15
15
  post "/tests/:id" do
16
- @test = Test.new(params[:id])
16
+ @test = Test.find(params[:id]) or pass
17
17
  @test.update_attributes(params)
18
18
  "OK"
19
19
  end
@@ -0,0 +1,21 @@
1
+ <div class="page-header">
2
+ <h1>Features</h1>
3
+ <p>Features are large pieces of functionality where you want to control their availability either for the purpose of testing or rolling out.</p>
4
+ </div>
5
+
6
+ <% if @features.any? %>
7
+ <div class="panel panel-primary">
8
+ <div class="panel-heading">
9
+ <h3 class="panel-title">Known Features</h3>
10
+ </div>
11
+ <ul class="list-group">
12
+ <% @features.each do |feature| %>
13
+ <li class="list-group-item"><a href="<%= url("features/#{feature.id}") %>"><%= feature.name %></a></li>
14
+ <% end %>
15
+ </ul>
16
+ </div>
17
+ <% else %>
18
+ <div class="alert alert-danger">
19
+ <strong>No features!</strong> Something may be wrong or you may just need to define some features.
20
+ </div>
21
+ <% end %>
@@ -0,0 +1,163 @@
1
+ <div class="page-header">
2
+ <div id="status-buttons-group" class="btn-group pull-right" data-current-status="<%= @feature.status %>">
3
+ <button type="button" class="btn" value="active" data-selected-class="btn-success">Active</button>
4
+ <button type="button" class="btn" value="rollout" data-selected-class="btn-warning">Roll Out</button>
5
+ <button type="button" class="btn" value="inactive" data-selected-class="btn-danger">Inactive</button>
6
+ </div>
7
+ <h1><span id="editable-name"><%= @feature.name %></span> <small>Feature</small></h1>
8
+ <p id="editable-description"><%= @feature.description %></p>
9
+ </div>
10
+
11
+ <div id="active" class="feature-status-panel hidden">
12
+ <p>This feature is <em>active</em> for all users.</p>
13
+ </div>
14
+
15
+ <div id="inactive" class="feature-status-panel hidden">
16
+ <p>This feature is <em>inactive</em> for all users.</p>
17
+ </div>
18
+
19
+ <div id="rollout" class="feature-status-panel hidden">
20
+ <div class="panel panel-primary">
21
+ <div class="panel-heading">
22
+ <h3 class="panel-title">Rollout Options</h3>
23
+ </div>
24
+ <div class="panel-body">
25
+ <p>Activate feature for all visitors:</p>
26
+ <div class="progress" id="editable-visitors-rate">
27
+ <div class="progress-bar progress-bar-warning" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%; overflow: visible;">
28
+ </div>
29
+ </div>
30
+ <p>Activate feature for logged in users:</p>
31
+ <div class="progress" id="editable-users-rate">
32
+ <div class="progress-bar progress-bar-warning" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%; overflow: visible;">
33
+ </div>
34
+ </div>
35
+ <p>Activate feature for group segments:</p>
36
+ <table class="table">
37
+ <thead>
38
+ <tr>
39
+ <th>Group</th>
40
+ <th>Active Segments</th>
41
+ </tr>
42
+ </thead>
43
+ <% @groups.each do |group| %>
44
+ <tr>
45
+ <td><%= group.name %></td>
46
+ <td><a href="#" id="editable-group-segments-<%= group.id %>"></a></td>
47
+ </tr>
48
+ <% end if @groups.any? %>
49
+ </table>
50
+ </div>
51
+ <div class="panel-footer">Note: 100% of visitors is the same as active. 50% of visitors &amp; 100% of logged in users will be more than 50% of all traffic.</div>
52
+ </div>
53
+ </div>
54
+
55
+ <!--Load the AJAX API-->
56
+ <script src="<%= url('javascripts/jquery-1.10.2.min.js') %>"></script>
57
+ <script src="<%= url('javascripts/bootstrap.min.js') %>"></script>
58
+ <script src="<%= url('bootstrap3-editable/js/bootstrap-editable.min.js') %>"></script>
59
+ <script type="text/javascript">
60
+ //turn to inline mode
61
+ $.fn.editable.defaults.send = 'always';
62
+ $.fn.editable.defaults.params = function(params) {
63
+ var obj = {};
64
+ obj[params.name] = params.value;
65
+ return obj;
66
+ }
67
+
68
+ $("#status-buttons-group").on("click", ":button", function (e) {
69
+ e.preventDefault();
70
+ var status = $(this).val();
71
+ $('#status-buttons-group').data("current-status", status);
72
+ $.post( "<%= url("features/#{@feature.id}") %>", { status: status } , updateButtonStates);
73
+ });
74
+
75
+ function updateButtonStates(){
76
+ var v = $('#status-buttons-group').data("current-status");
77
+ $( "#status-buttons-group :button" ).removeClass();
78
+ $( "#status-buttons-group :button" ).addClass("btn");
79
+ var selectedClass = $( "#status-buttons-group :button[value='"+v+"']" ).data("selected-class");
80
+ $( "#status-buttons-group :button[value='"+v+"']" ).addClass( selectedClass );
81
+ $( ".feature-status-panel" ).addClass("hidden");
82
+ $( "#"+v ).removeClass("hidden");
83
+ }
84
+
85
+ $(document).ready(updateButtonStates);
86
+
87
+ $(document).ready(function() {
88
+ <% @groups.each do |group| %>
89
+ $('#editable-group-segments-<%= group.id %>').editable({
90
+ type: 'checklist',
91
+ emptytext: 'No segments active',
92
+ title: "Select segments",
93
+ url: '<%= url("features/#{@feature.id}/groups/#{group.id}")%>',
94
+ name: 'segments',
95
+ value: [<%= @feature.segments_for(group.id).map{|s| "'#{s}'"}.join(',') %>],
96
+ source: [<%= group.segments.map{|s| "{value: '#{s}', text: '#{s}'}"}.join(',') %>]
97
+ });
98
+ <% end %>
99
+
100
+ $('#editable-users-rate').editable({
101
+ type: 'number',
102
+ value: <%= @feature.users_rate %>,
103
+ display: function(value, sourceData) {
104
+ var color = "#fff";
105
+ if (value < 10) {
106
+ color = "#000";
107
+ }
108
+ $(".progress-bar", this).attr("aria-valuenow", value).html(value + "% of users").width(value+'%').css('color', color);
109
+ },
110
+ url: '<%= url("features/#{@feature.id}") %>',
111
+ name: 'users_rate',
112
+ title: '% of logged in users',
113
+ validate: function(value) {
114
+ if($.trim(value) == '') {
115
+ return 'This field is required';
116
+ }
117
+ }
118
+ });
119
+
120
+
121
+ $('#editable-visitors-rate').editable({
122
+ type: 'number',
123
+ value: <%= @feature.visitors_rate %>,
124
+ display: function(value, sourceData) {
125
+ var color = "#fff";
126
+ if (value < 10) {
127
+ color = "#000";
128
+ }
129
+ $(".progress-bar", this).attr("aria-valuenow", value).html(value + "% of visitors").width(value+'%').css('color', color);
130
+ },
131
+ url: '<%= url("features/#{@feature.id}") %>',
132
+ name: 'visitors_rate',
133
+ title: '% of all visitors',
134
+ validate: function(value) {
135
+ if($.trim(value) == '') {
136
+ return 'This field is required';
137
+ }
138
+ }
139
+ });
140
+
141
+
142
+ $('#editable-name').editable({
143
+ placement: 'right',
144
+ type: 'text',
145
+ url: '<%= url("features/#{@feature.id}") %>',
146
+ name: 'name',
147
+ title: 'Edit feature name',
148
+ validate: function(value) {
149
+ if($.trim(value) == '') {
150
+ return 'This field is required';
151
+ }
152
+ }
153
+ });
154
+ $('#editable-description').editable({
155
+ placement: 'bottom',
156
+ emptytext: 'Give this feature a description',
157
+ type: 'textarea',
158
+ url: '<%= url("features/#{@feature.id}") %>',
159
+ name: 'description',
160
+ title: 'Edit feature description'
161
+ });
162
+ });
163
+ </script>
@@ -1,5 +1,5 @@
1
1
  <div class="page-header">
2
- <h1><span id="editable-name"><%= @group.name %></span></h1>
2
+ <h1><span id="editable-name"><%= @group.name %></span> <small>Group</small></h1>
3
3
  <p id="editable-description"><%= @group.description %></p>
4
4
  </div>
5
5
 
@@ -33,9 +33,10 @@
33
33
  </div>
34
34
  <div class="navbar-collapse collapse">
35
35
  <ul class="nav navbar-nav">
36
+ <li><a href="<%= url('features') %>">Features</a></li>
36
37
  <li><a href="<%= url('groups') %>">Groups</a></li>
37
38
  <li><a href="<%= url('metrics') %>">Metrics</a></li>
38
- <li><a href="<%= url('tests') %>">Tests</a></li>
39
+ <!-- li><a href="<%= url('tests') %>">Tests</a></li -->
39
40
  </ul>
40
41
  </div><!--/.nav-collapse -->
41
42
  </div>
@@ -2,15 +2,26 @@ require "blackbeard/configuration"
2
2
  require "blackbeard/pirate"
3
3
 
4
4
  module Blackbeard
5
- extend self
6
- attr_accessor :config
5
+ class << self
7
6
 
8
- def self.pirate
9
- @config ||= Configuration.new
10
- yield(config)
11
- Blackbeard::Pirate.new
12
- end
7
+ def configure!
8
+ @config = Configuration.new
9
+ yield config
10
+ end
11
+
12
+ def config
13
+ @config ||= Configuration.new
14
+ end
13
15
 
16
+ def configure
17
+ yield config
18
+ end
19
+
20
+ def pirate
21
+ yield(config) if block_given?
22
+ Blackbeard::Pirate.new
23
+ end
24
+ end
14
25
  end
15
26
 
16
27
  Blackbeard.pirate {}