blackbeard 0.0.3.1 → 0.0.4.0
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.
- checksums.yaml +4 -4
- data/README.md +31 -20
- data/dashboard/public/stylesheets/application.css +4 -0
- data/dashboard/routes/features.rb +31 -0
- data/dashboard/routes/groups.rb +2 -2
- data/dashboard/routes/metrics.rb +6 -6
- data/dashboard/routes/tests.rb +2 -2
- data/dashboard/views/features/index.erb +21 -0
- data/dashboard/views/features/show.erb +163 -0
- data/dashboard/views/groups/show.erb +1 -1
- data/dashboard/views/layout.erb +2 -1
- data/lib/blackbeard.rb +18 -7
- data/lib/blackbeard/configuration.rb +3 -2
- data/lib/blackbeard/context.rb +11 -7
- data/lib/blackbeard/dashboard.rb +2 -0
- data/lib/blackbeard/errors.rb +4 -0
- data/lib/blackbeard/feature.rb +45 -0
- data/lib/blackbeard/feature_rollout.rb +47 -0
- data/lib/blackbeard/metric.rb +22 -4
- data/lib/blackbeard/pirate.rb +11 -5
- data/lib/blackbeard/redis_store.rb +4 -0
- data/lib/blackbeard/storable.rb +72 -6
- data/lib/blackbeard/storable_attributes.rb +50 -3
- data/lib/blackbeard/storable_has_many.rb +1 -0
- data/lib/blackbeard/storable_has_set.rb +1 -0
- data/lib/blackbeard/version.rb +1 -1
- data/spec/blackbeard_spec.rb +13 -0
- data/spec/configuration_spec.rb +6 -0
- data/spec/context_spec.rb +12 -12
- data/spec/dashboard/features_spec.rb +52 -0
- data/spec/dashboard/groups_spec.rb +4 -4
- data/spec/dashboard/metrics_spec.rb +6 -6
- data/spec/dashboard/tests_spec.rb +4 -4
- data/spec/feature_rollout_spec.rb +147 -0
- data/spec/feature_spec.rb +58 -0
- data/spec/group_spec.rb +1 -1
- data/spec/metric_data/total_spec.rb +1 -1
- data/spec/metric_data/unique_spec.rb +1 -1
- data/spec/metric_spec.rb +5 -5
- data/spec/pirate_spec.rb +16 -12
- data/spec/redis_store_spec.rb +6 -0
- data/spec/spec_helper.rb +3 -1
- data/spec/storable_attributes_spec.rb +58 -6
- data/spec/storable_has_many_spec.rb +2 -2
- data/spec/storable_has_set_spec.rb +1 -1
- data/spec/storable_spec.rb +119 -23
- data/spec/test_spec.rb +1 -1
- metadata +13 -2
@@ -22,9 +22,10 @@ module Blackbeard
|
|
22
22
|
@tz ||= TZInfo::Timezone.get(@timezone)
|
23
23
|
end
|
24
24
|
|
25
|
-
def define_group(id, &block)
|
25
|
+
def define_group(id, segments = nil, &block)
|
26
|
+
group = Group.find_or_create(id)
|
27
|
+
group.add_segments(segments || id)
|
26
28
|
@group_definitions[id.to_sym] = block
|
27
|
-
Group.new(id)
|
28
29
|
end
|
29
30
|
end
|
30
31
|
end
|
data/lib/blackbeard/context.rb
CHANGED
@@ -37,21 +37,25 @@ module Blackbeard
|
|
37
37
|
end
|
38
38
|
end
|
39
39
|
|
40
|
-
def
|
41
|
-
|
40
|
+
def feature_active?(id)
|
41
|
+
@pirate.feature(id.to_s).reload.active_for?(self)
|
42
42
|
end
|
43
43
|
|
44
44
|
def unique_identifier
|
45
|
-
@user.nil? ? "b#{
|
45
|
+
@user.nil? ? "b#{visitor_id}" : "a#{@user.id}"
|
46
46
|
end
|
47
47
|
|
48
|
-
|
48
|
+
def user_id
|
49
|
+
@user.id
|
50
|
+
end
|
49
51
|
|
50
|
-
def
|
51
|
-
controller.request.cookies[:bbd] ||=
|
52
|
+
def visitor_id
|
53
|
+
controller.request.cookies[:bbd] ||= generate_visitor_id
|
52
54
|
end
|
53
55
|
|
54
|
-
|
56
|
+
private
|
57
|
+
|
58
|
+
def generate_visitor_id
|
55
59
|
id = db.increment("visitor_id")
|
56
60
|
controller.request.cookies[:bbd] = { :value => id, :expires => Time.now + 31536000 }
|
57
61
|
id
|
data/lib/blackbeard/dashboard.rb
CHANGED
@@ -8,6 +8,7 @@ require 'routes/home'
|
|
8
8
|
require 'routes/groups'
|
9
9
|
require 'routes/metrics'
|
10
10
|
require 'routes/tests'
|
11
|
+
require 'routes/features'
|
11
12
|
|
12
13
|
module Blackbeard
|
13
14
|
class Dashboard < Sinatra::Base
|
@@ -19,6 +20,7 @@ module Blackbeard
|
|
19
20
|
use DashboardRoutes::Metrics
|
20
21
|
use DashboardRoutes::Tests
|
21
22
|
use DashboardRoutes::Groups
|
23
|
+
use DashboardRoutes::Features
|
22
24
|
end
|
23
25
|
end
|
24
26
|
|
data/lib/blackbeard/errors.rb
CHANGED
@@ -1,4 +1,8 @@
|
|
1
1
|
module Blackbeard
|
2
2
|
class GroupNotInMetric < StandardError; end
|
3
3
|
class StorableMasterKeyUndefined < StandardError; end
|
4
|
+
class StorableNotFound < StandardError; end
|
5
|
+
class StorableDuplicateKey < StandardError; end
|
6
|
+
class StorableNotSaved < StandardError; end
|
7
|
+
class UserIdNotDivisable < StandardError; end
|
4
8
|
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'blackbeard/storable'
|
2
|
+
require 'blackbeard/feature_rollout'
|
3
|
+
module Blackbeard
|
4
|
+
class Feature < Storable
|
5
|
+
include FeatureRollout
|
6
|
+
|
7
|
+
set_master_key :features
|
8
|
+
string_attributes :name, :description, :status
|
9
|
+
integer_attributes :visitors_rate, :users_rate
|
10
|
+
json_attributes :group_segments
|
11
|
+
|
12
|
+
def segments_for(group_id)
|
13
|
+
(group_segments && group_segments[group_id.to_s]) || []
|
14
|
+
end
|
15
|
+
|
16
|
+
def set_segments_for(group_id, *segments)
|
17
|
+
segments = Array(segments).flatten.compact.map{|a| a.to_s}
|
18
|
+
grp_segments = self.group_segments || {}
|
19
|
+
grp_segments[group_id.to_s] = segments
|
20
|
+
self.group_segments = grp_segments
|
21
|
+
end
|
22
|
+
|
23
|
+
def active_for?(context)
|
24
|
+
case status
|
25
|
+
when 'active'
|
26
|
+
true
|
27
|
+
when 'rollout'
|
28
|
+
rollout?(context)
|
29
|
+
else
|
30
|
+
false
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def status
|
35
|
+
storable_attributes_hash['status'] || "inactive"
|
36
|
+
end
|
37
|
+
|
38
|
+
def name
|
39
|
+
storable_attributes_hash['name'] || id
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Blackbeard
|
2
|
+
module FeatureRollout
|
3
|
+
def rollout?(context)
|
4
|
+
active_user?(context) || active_visitor?(context) || active_segment?(context)
|
5
|
+
end
|
6
|
+
|
7
|
+
def active_segment?(context)
|
8
|
+
return false unless group_segments
|
9
|
+
#TODO: speed this up. memoize group on the feature. store segment in user session
|
10
|
+
group_segments.each_pair do |group_id, segments|
|
11
|
+
next if segments.nil? || segments.empty?
|
12
|
+
group = Group.find(group_id) or next
|
13
|
+
user_segment = group.segment_for(context) or next
|
14
|
+
return true if segments.include?(user_segment)
|
15
|
+
end
|
16
|
+
false
|
17
|
+
end
|
18
|
+
|
19
|
+
def active_user?(context)
|
20
|
+
return false if (users_rate.zero? || !context.user)
|
21
|
+
return true if users_rate == 100
|
22
|
+
|
23
|
+
user_id = id_to_int(context.user_id)
|
24
|
+
(user_id % 100).between?(1,users_rate)
|
25
|
+
end
|
26
|
+
|
27
|
+
def active_visitor?(context)
|
28
|
+
return false if visitors_rate.zero?
|
29
|
+
return true if visitors_rate == 100
|
30
|
+
(context.visitor_id % 100).between?(1,visitors_rate)
|
31
|
+
end
|
32
|
+
|
33
|
+
def id_to_int(id)
|
34
|
+
if id.kind_of?(Integer)
|
35
|
+
id
|
36
|
+
elsif id.kind_of?(String)
|
37
|
+
bytes = id.bytes
|
38
|
+
if bytes.count > 8
|
39
|
+
bytes = bytes[-8..-1]
|
40
|
+
end
|
41
|
+
bytes.inject { |sum, n| sum * n }
|
42
|
+
else
|
43
|
+
raise UserIdNotDivisable
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
data/lib/blackbeard/metric.rb
CHANGED
@@ -9,13 +9,30 @@ module Blackbeard
|
|
9
9
|
string_attributes :name, :description
|
10
10
|
has_many :groups => Group
|
11
11
|
|
12
|
-
def
|
13
|
-
|
14
|
-
|
15
|
-
|
12
|
+
def self.create(type, type_id, options = {})
|
13
|
+
super("#{type}::#{type_id}", options)
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.find(type, type_id)
|
17
|
+
super("#{type}::#{type_id}")
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.find_or_create(type, type_id)
|
16
21
|
super("#{type}::#{type_id}")
|
17
22
|
end
|
18
23
|
|
24
|
+
def initialize(*args)
|
25
|
+
if args.size == 1 && args[0] =~ /::/
|
26
|
+
@type, @type_id = args[0].split(/::/)
|
27
|
+
elsif args.size == 2
|
28
|
+
@type = args[0]
|
29
|
+
@type_id = args[1]
|
30
|
+
else
|
31
|
+
raise ArgumentError
|
32
|
+
end
|
33
|
+
super("#{@type}::#{@type_id}")
|
34
|
+
end
|
35
|
+
|
19
36
|
def self.new_from_key(key)
|
20
37
|
if key =~ /^#{master_key}::(.+)::(.+)$/
|
21
38
|
new($1,$2)
|
@@ -42,6 +59,7 @@ module Blackbeard
|
|
42
59
|
end
|
43
60
|
|
44
61
|
def metric_data(group = nil)
|
62
|
+
@metric_data ||= {}
|
45
63
|
@metric_data[group] ||= begin
|
46
64
|
raise GroupNotInMetric unless group.nil? || has_group?(group)
|
47
65
|
MetricData.const_get(type.capitalize).new(self, group)
|
data/lib/blackbeard/pirate.rb
CHANGED
@@ -5,20 +5,26 @@ require "blackbeard/metric_data/total"
|
|
5
5
|
require "blackbeard/test"
|
6
6
|
require "blackbeard/errors"
|
7
7
|
require "blackbeard/group"
|
8
|
+
require "blackbeard/feature"
|
8
9
|
|
9
10
|
module Blackbeard
|
10
11
|
class Pirate
|
11
12
|
def initialize
|
12
13
|
@metrics = {}
|
13
14
|
@tests = {}
|
15
|
+
@features = {}
|
14
16
|
end
|
15
17
|
|
16
18
|
def metric(type, type_id)
|
17
|
-
@metrics["#{type}::#{type_id}"] ||= Metric.
|
19
|
+
@metrics["#{type}::#{type_id}"] ||= Metric.find_or_create(type, type_id)
|
18
20
|
end
|
19
21
|
|
20
22
|
def test(id)
|
21
|
-
@tests[id] ||= Test.
|
23
|
+
@tests[id] ||= Test.find_or_create(id)
|
24
|
+
end
|
25
|
+
|
26
|
+
def feature(id)
|
27
|
+
@features[id] ||= Feature.find_or_create(id)
|
22
28
|
end
|
23
29
|
|
24
30
|
def context(*args)
|
@@ -48,9 +54,9 @@ module Blackbeard
|
|
48
54
|
@set_context.ab_test(id, options)
|
49
55
|
end
|
50
56
|
|
51
|
-
def
|
52
|
-
return
|
53
|
-
@set_context.
|
57
|
+
def feature_active?(id)
|
58
|
+
return false unless @set_context
|
59
|
+
@set_context.feature_active?(id)
|
54
60
|
end
|
55
61
|
|
56
62
|
|
data/lib/blackbeard/storable.rb
CHANGED
@@ -6,9 +6,6 @@ require 'blackbeard/storable_has_set'
|
|
6
6
|
module Blackbeard
|
7
7
|
class Storable
|
8
8
|
include ConfigurationMethods
|
9
|
-
include StorableHasMany
|
10
|
-
include StorableHasSet
|
11
|
-
include StorableAttributes
|
12
9
|
|
13
10
|
class << self
|
14
11
|
def set_master_key(master_key)
|
@@ -20,13 +17,59 @@ module Blackbeard
|
|
20
17
|
return self.superclass.master_key if self.superclass.respond_to?(:master_key)
|
21
18
|
raise StorableMasterKeyUndefined, "define master key in the class that inherits from storable"
|
22
19
|
end
|
20
|
+
|
21
|
+
def on_save(method)
|
22
|
+
on_save_methods.push(method)
|
23
|
+
end
|
24
|
+
|
25
|
+
def on_save_methods
|
26
|
+
@on_save_methods ||= self.superclass.on_save_methods.dup if self.superclass.respond_to?(:on_save_methods)
|
27
|
+
@on_save_methods ||= []
|
28
|
+
end
|
29
|
+
|
30
|
+
def on_reload(method)
|
31
|
+
on_reload_methods.push(method)
|
32
|
+
end
|
33
|
+
|
34
|
+
def on_reload_methods
|
35
|
+
@on_reload_methods ||= self.superclass.on_reload_methods.dup if self.superclass.respond_to?(:on_reload_methods)
|
36
|
+
@on_reload_methods ||= []
|
37
|
+
end
|
23
38
|
end
|
24
39
|
|
40
|
+
include StorableHasMany
|
41
|
+
include StorableHasSet
|
42
|
+
include StorableAttributes
|
43
|
+
|
25
44
|
attr_reader :id
|
45
|
+
attr_accessor :new_record
|
26
46
|
|
27
47
|
def initialize(id)
|
28
48
|
@id = id.to_s.downcase
|
29
|
-
|
49
|
+
@new_record = true
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.find(id)
|
53
|
+
key = key_for(id)
|
54
|
+
return nil unless db.hash_field_exists(master_key, key)
|
55
|
+
storable = new(id)
|
56
|
+
storable.new_record = false
|
57
|
+
storable
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.create(id, attributes = {})
|
61
|
+
key = key_for(id)
|
62
|
+
raise StorableDuplicateKey if db.hash_field_exists(master_key, key)
|
63
|
+
storable = new(id)
|
64
|
+
storable.save
|
65
|
+
storable.update_attributes(attributes) unless attributes.empty?
|
66
|
+
storable
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.find_or_create(id)
|
70
|
+
storable = new(id)
|
71
|
+
storable.save
|
72
|
+
storable
|
30
73
|
end
|
31
74
|
|
32
75
|
def self.all_keys
|
@@ -43,7 +86,9 @@ module Blackbeard
|
|
43
86
|
|
44
87
|
def self.new_from_key(key)
|
45
88
|
if key =~ /^#{master_key}::(.+)$/
|
46
|
-
new($1)
|
89
|
+
storable = new($1)
|
90
|
+
storable.new_record = false
|
91
|
+
storable
|
47
92
|
else
|
48
93
|
nil
|
49
94
|
end
|
@@ -53,12 +98,33 @@ module Blackbeard
|
|
53
98
|
all_keys.map{ |key| new_from_key(key) }
|
54
99
|
end
|
55
100
|
|
101
|
+
def save
|
102
|
+
if new_record?
|
103
|
+
db.hash_key_set_if_not_exists(master_key, key, tz.now.to_date)
|
104
|
+
@new_record = false
|
105
|
+
end
|
106
|
+
self.class.on_save_methods.each{ |m| self.send(m) }
|
107
|
+
end
|
108
|
+
|
109
|
+
def reload
|
110
|
+
self.class.on_reload_methods.each{ |m| self.send(m) }
|
111
|
+
self
|
112
|
+
end
|
113
|
+
|
114
|
+
def new_record?
|
115
|
+
@new_record
|
116
|
+
end
|
117
|
+
|
56
118
|
def ==(o)
|
57
119
|
o.class == self.class && o.id == self.id
|
58
120
|
end
|
59
121
|
|
122
|
+
def self.key_for(id)
|
123
|
+
"#{master_key}::#{ id.to_s.downcase }"
|
124
|
+
end
|
125
|
+
|
60
126
|
def key
|
61
|
-
|
127
|
+
self.class.key_for(id)
|
62
128
|
end
|
63
129
|
|
64
130
|
def master_key
|
@@ -1,8 +1,12 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
1
3
|
module Blackbeard
|
2
4
|
module StorableAttributes
|
3
5
|
def self.included(base)
|
4
|
-
base.send :include, InstanceMethods
|
5
6
|
base.extend ClassMethods
|
7
|
+
base.send :include, InstanceMethods
|
8
|
+
base.send :on_save, :save_storable_attributes
|
9
|
+
base.send :on_reload, :reload_storable_attributes
|
6
10
|
end
|
7
11
|
|
8
12
|
module ClassMethods
|
@@ -16,6 +20,35 @@ module Blackbeard
|
|
16
20
|
[]
|
17
21
|
end
|
18
22
|
|
23
|
+
def integer_attributes(*attributes)
|
24
|
+
self.storable_attributes += attributes
|
25
|
+
attributes.each do |method_name|
|
26
|
+
method_name = method_name.to_sym
|
27
|
+
send :define_method, method_name do
|
28
|
+
storable_attributes_hash[method_name.to_s].to_i
|
29
|
+
end
|
30
|
+
send :define_method, "#{method_name}=".to_sym do |value|
|
31
|
+
storable_attributes_hash[method_name.to_s] = value.to_i.to_s
|
32
|
+
@storable_attributes_dirty = true
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def json_attributes(*attributes)
|
38
|
+
self.storable_attributes += attributes
|
39
|
+
attributes.each do |method_name|
|
40
|
+
method_name = method_name.to_sym
|
41
|
+
send :define_method, method_name do
|
42
|
+
return nil if storable_attributes_hash[method_name.to_s].nil?
|
43
|
+
JSON.parse(storable_attributes_hash[method_name.to_s])
|
44
|
+
end
|
45
|
+
send :define_method, "#{method_name}=".to_sym do |value|
|
46
|
+
storable_attributes_hash[method_name.to_s] = JSON.generate(value)
|
47
|
+
@storable_attributes_dirty = true
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
19
52
|
def string_attributes(*attributes)
|
20
53
|
self.storable_attributes += attributes
|
21
54
|
attributes.each do |method_name|
|
@@ -24,8 +57,8 @@ module Blackbeard
|
|
24
57
|
storable_attributes_hash[method_name.to_s]
|
25
58
|
end
|
26
59
|
send :define_method, "#{method_name}=".to_sym do |value|
|
27
|
-
|
28
|
-
|
60
|
+
storable_attributes_hash[method_name.to_s] = value.to_s
|
61
|
+
@storable_attributes_dirty = true
|
29
62
|
end
|
30
63
|
end
|
31
64
|
end
|
@@ -38,6 +71,20 @@ module Blackbeard
|
|
38
71
|
safe_attributes.each do |attribute|
|
39
72
|
self.send("#{attribute}=".to_sym, tainted_params[attribute])
|
40
73
|
end
|
74
|
+
save_storable_attributes
|
75
|
+
end
|
76
|
+
|
77
|
+
def save_storable_attributes
|
78
|
+
raise StorableNotSaved if new_record?
|
79
|
+
if @storable_attributes_dirty
|
80
|
+
db.hash_multi_set(attributes_hash_key, storable_attributes_hash)
|
81
|
+
@storable_attributes_dirty = false
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def reload_storable_attributes
|
86
|
+
@storable_attributes = nil
|
87
|
+
@storable_attributes_dirty = false
|
41
88
|
end
|
42
89
|
|
43
90
|
def storable_attributes_hash
|