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.
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
@@ -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
@@ -37,21 +37,25 @@ module Blackbeard
37
37
  end
38
38
  end
39
39
 
40
- def active?(id)
41
- ab_test(id) == :active
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#{blackbeard_visitor_id}" : "a#{@user.id}"
45
+ @user.nil? ? "b#{visitor_id}" : "a#{@user.id}"
46
46
  end
47
47
 
48
- private
48
+ def user_id
49
+ @user.id
50
+ end
49
51
 
50
- def blackbeard_visitor_id
51
- controller.request.cookies[:bbd] ||= generate_blackbeard_visitor_id
52
+ def visitor_id
53
+ controller.request.cookies[:bbd] ||= generate_visitor_id
52
54
  end
53
55
 
54
- def generate_blackbeard_visitor_id
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
@@ -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
 
@@ -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
@@ -9,13 +9,30 @@ module Blackbeard
9
9
  string_attributes :name, :description
10
10
  has_many :groups => Group
11
11
 
12
- def initialize(type, type_id)
13
- @type = type
14
- @type_id = type_id
15
- @metric_data = {}
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)
@@ -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.new(type, type_id)
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.new(id)
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 active?(id)
52
- return self unless @set_context
53
- @set_context.active?(id)
57
+ def feature_active?(id)
58
+ return false unless @set_context
59
+ @set_context.feature_active?(id)
54
60
  end
55
61
 
56
62
 
@@ -48,6 +48,10 @@ module Blackbeard
48
48
  redis.hincrbyfloat(hash_key, field, float)
49
49
  end
50
50
 
51
+ def hash_field_exists(hash_key, field)
52
+ redis.hexists(hash_key, field)
53
+ end
54
+
51
55
  # Set commands
52
56
  def set_members(set_key)
53
57
  redis.smembers(set_key)
@@ -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
- db.hash_key_set_if_not_exists(master_key, key, tz.now.to_date)
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
- "#{master_key}::#{ id }"
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
- db.hash_set(attributes_hash_key, method_name, value)
28
- storable_attributes_hash[method_name.to_s] = value
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