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