rollout 2.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.circleci/config.yml +95 -0
- data/.gitignore +24 -0
- data/.rspec +1 -0
- data/.rubocop.yml +11 -0
- data/.travis.yml +20 -0
- data/Gemfile +5 -0
- data/LICENSE +20 -0
- data/README.md +201 -0
- data/Rakefile +7 -0
- data/lib/rollout.rb +227 -0
- data/lib/rollout/feature.rb +131 -0
- data/lib/rollout/logging.rb +198 -0
- data/lib/rollout/version.rb +5 -0
- data/rollout.gemspec +31 -0
- data/spec/rollout/logging_spec.rb +143 -0
- data/spec/rollout_spec.rb +730 -0
- data/spec/spec_helper.rb +27 -0
- metadata +158 -0
@@ -0,0 +1,131 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Rollout
|
4
|
+
class Feature
|
5
|
+
attr_accessor :groups, :users, :percentage, :data
|
6
|
+
attr_reader :name, :options
|
7
|
+
|
8
|
+
def initialize(name, string = nil, opts = {})
|
9
|
+
@options = opts
|
10
|
+
@name = name
|
11
|
+
|
12
|
+
if string
|
13
|
+
raw_percentage, raw_users, raw_groups, raw_data = string.split('|', 4)
|
14
|
+
@percentage = raw_percentage.to_f
|
15
|
+
@users = users_from_string(raw_users)
|
16
|
+
@groups = groups_from_string(raw_groups)
|
17
|
+
@data = raw_data.nil? || raw_data.strip.empty? ? {} : JSON.parse(raw_data)
|
18
|
+
else
|
19
|
+
clear
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def serialize
|
24
|
+
"#{@percentage}|#{@users.to_a.join(',')}|#{@groups.to_a.join(',')}|#{serialize_data}"
|
25
|
+
end
|
26
|
+
|
27
|
+
def add_user(user)
|
28
|
+
id = user_id(user)
|
29
|
+
@users << id unless @users.include?(id)
|
30
|
+
end
|
31
|
+
|
32
|
+
def remove_user(user)
|
33
|
+
@users.delete(user_id(user))
|
34
|
+
end
|
35
|
+
|
36
|
+
def add_group(group)
|
37
|
+
@groups << group.to_sym unless @groups.include?(group.to_sym)
|
38
|
+
end
|
39
|
+
|
40
|
+
def remove_group(group)
|
41
|
+
@groups.delete(group.to_sym)
|
42
|
+
end
|
43
|
+
|
44
|
+
def clear
|
45
|
+
@groups = groups_from_string('')
|
46
|
+
@users = users_from_string('')
|
47
|
+
@percentage = 0
|
48
|
+
@data = {}
|
49
|
+
end
|
50
|
+
|
51
|
+
def active?(rollout, user)
|
52
|
+
if user
|
53
|
+
id = user_id(user)
|
54
|
+
user_in_percentage?(id) ||
|
55
|
+
user_in_active_users?(id) ||
|
56
|
+
user_in_active_group?(user, rollout)
|
57
|
+
else
|
58
|
+
@percentage == 100
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def user_in_active_users?(user)
|
63
|
+
@users.include?(user_id(user))
|
64
|
+
end
|
65
|
+
|
66
|
+
def to_hash
|
67
|
+
{
|
68
|
+
percentage: @percentage,
|
69
|
+
groups: @groups,
|
70
|
+
users: @users,
|
71
|
+
data: @data,
|
72
|
+
}
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def user_id(user)
|
78
|
+
if user.is_a?(Integer) || user.is_a?(String)
|
79
|
+
user.to_s
|
80
|
+
else
|
81
|
+
user.send(id_user_by).to_s
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def id_user_by
|
86
|
+
@options[:id_user_by] || :id
|
87
|
+
end
|
88
|
+
|
89
|
+
def user_in_percentage?(user)
|
90
|
+
Zlib.crc32(user_id_for_percentage(user)) < RAND_BASE * @percentage
|
91
|
+
end
|
92
|
+
|
93
|
+
def user_id_for_percentage(user)
|
94
|
+
if @options[:randomize_percentage]
|
95
|
+
user_id(user).to_s + @name.to_s
|
96
|
+
else
|
97
|
+
user_id(user)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def user_in_active_group?(user, rollout)
|
102
|
+
@groups.any? do |g|
|
103
|
+
rollout.active_in_group?(g, user)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def serialize_data
|
108
|
+
return '' unless @data.is_a? Hash
|
109
|
+
|
110
|
+
@data.to_json
|
111
|
+
end
|
112
|
+
|
113
|
+
def users_from_string(raw_users)
|
114
|
+
users = (raw_users || '').split(',').map(&:to_s)
|
115
|
+
if @options[:use_sets]
|
116
|
+
users.to_set
|
117
|
+
else
|
118
|
+
users
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def groups_from_string(raw_groups)
|
123
|
+
groups = (raw_groups || '').split(',').map(&:to_sym)
|
124
|
+
if @options[:use_sets]
|
125
|
+
groups.to_set
|
126
|
+
else
|
127
|
+
groups
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,198 @@
|
|
1
|
+
class Rollout
|
2
|
+
module Logging
|
3
|
+
def self.extended(rollout)
|
4
|
+
options = rollout.options[:logging]
|
5
|
+
options = options.is_a?(Hash) ? options.dup : {}
|
6
|
+
options[:storage] ||= rollout.storage
|
7
|
+
|
8
|
+
logger = Logger.new(**options)
|
9
|
+
|
10
|
+
rollout.add_observer(logger, :log)
|
11
|
+
rollout.define_singleton_method(:logging) do
|
12
|
+
logger
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class Event
|
17
|
+
attr_reader :feature, :name, :data, :context, :created_at
|
18
|
+
|
19
|
+
def self.from_raw(value, score)
|
20
|
+
hash = JSON.parse(value, symbolize_names: true)
|
21
|
+
|
22
|
+
new(**hash.merge(created_at: Time.at(-score.to_f / 1_000_000)))
|
23
|
+
end
|
24
|
+
|
25
|
+
def initialize(feature: nil, name:, data:, context: {}, created_at:)
|
26
|
+
@feature = feature
|
27
|
+
@name = name
|
28
|
+
@data = data
|
29
|
+
@context = context
|
30
|
+
@created_at = created_at
|
31
|
+
end
|
32
|
+
|
33
|
+
def timestamp
|
34
|
+
(@created_at.to_f * 1_000_000).to_i
|
35
|
+
end
|
36
|
+
|
37
|
+
def serialize
|
38
|
+
JSON.dump(
|
39
|
+
feature: @feature,
|
40
|
+
name: @name,
|
41
|
+
data: @data,
|
42
|
+
context: @context,
|
43
|
+
)
|
44
|
+
end
|
45
|
+
|
46
|
+
def ==(other)
|
47
|
+
feature == other.feature \
|
48
|
+
&& name == other.name \
|
49
|
+
&& data == other.data \
|
50
|
+
&& created_at == other.created_at
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class Logger
|
55
|
+
def initialize(storage: nil, history_length: 50, global: false)
|
56
|
+
@history_length = history_length
|
57
|
+
@storage = storage
|
58
|
+
@global = global
|
59
|
+
end
|
60
|
+
|
61
|
+
def updated_at(feature_name)
|
62
|
+
storage_key = events_storage_key(feature_name)
|
63
|
+
_, score = @storage.zrange(storage_key, 0, 0, with_scores: true).first
|
64
|
+
Time.at(-score.to_f / 1_000_000) if score
|
65
|
+
end
|
66
|
+
|
67
|
+
def last_event(feature_name)
|
68
|
+
storage_key = events_storage_key(feature_name)
|
69
|
+
value = @storage.zrange(storage_key, 0, 0, with_scores: true).first
|
70
|
+
Event.from_raw(*value) if value
|
71
|
+
end
|
72
|
+
|
73
|
+
def events(feature_name)
|
74
|
+
storage_key = events_storage_key(feature_name)
|
75
|
+
@storage
|
76
|
+
.zrange(storage_key, 0, -1, with_scores: true)
|
77
|
+
.map { |v| Event.from_raw(*v) }
|
78
|
+
.reverse
|
79
|
+
end
|
80
|
+
|
81
|
+
def global_events
|
82
|
+
@storage
|
83
|
+
.zrange(global_events_storage_key, 0, -1, with_scores: true)
|
84
|
+
.map { |v| Event.from_raw(*v) }
|
85
|
+
.reverse
|
86
|
+
end
|
87
|
+
|
88
|
+
def delete(feature_name)
|
89
|
+
storage_key = events_storage_key(feature_name)
|
90
|
+
@storage.del(storage_key)
|
91
|
+
end
|
92
|
+
|
93
|
+
def update(before, after)
|
94
|
+
before_hash = before.to_hash
|
95
|
+
before_hash.delete(:data).each do |k, v|
|
96
|
+
before_hash["data.#{k}"] = v
|
97
|
+
end
|
98
|
+
after_hash = after.to_hash
|
99
|
+
after_hash.delete(:data).each do |k, v|
|
100
|
+
after_hash["data.#{k}"] = v
|
101
|
+
end
|
102
|
+
|
103
|
+
keys = before_hash.keys | after_hash.keys
|
104
|
+
change = { before: {}, after: {} }
|
105
|
+
changed_count = 0
|
106
|
+
|
107
|
+
keys.each do |key|
|
108
|
+
next if before_hash[key] == after_hash[key]
|
109
|
+
|
110
|
+
change[:before][key] = before_hash[key]
|
111
|
+
change[:after][key] = after_hash[key]
|
112
|
+
|
113
|
+
changed_count += 1
|
114
|
+
end
|
115
|
+
|
116
|
+
return if changed_count == 0
|
117
|
+
|
118
|
+
event = Event.new(
|
119
|
+
feature: after.name,
|
120
|
+
name: :update,
|
121
|
+
data: change,
|
122
|
+
context: current_context,
|
123
|
+
created_at: Time.now,
|
124
|
+
)
|
125
|
+
|
126
|
+
storage_key = events_storage_key(after.name)
|
127
|
+
|
128
|
+
@storage.zadd(storage_key, -event.timestamp, event.serialize)
|
129
|
+
@storage.zremrangebyrank(storage_key, @history_length, -1)
|
130
|
+
|
131
|
+
if @global
|
132
|
+
@storage.zadd(global_events_storage_key, -event.timestamp, event.serialize)
|
133
|
+
@storage.zremrangebyrank(global_events_storage_key, @history_length, -1)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def log(event, *args)
|
138
|
+
return unless logging_enabled?
|
139
|
+
|
140
|
+
unless respond_to?(event)
|
141
|
+
raise ArgumentError, "Invalid log event: #{event}"
|
142
|
+
end
|
143
|
+
|
144
|
+
expected_arity = method(event).arity
|
145
|
+
unless args.count == expected_arity
|
146
|
+
raise(
|
147
|
+
ArgumentError,
|
148
|
+
"Invalid number of arguments for event '#{event}': expected #{expected_arity} but got #{args.count}",
|
149
|
+
)
|
150
|
+
end
|
151
|
+
|
152
|
+
public_send(event, *args)
|
153
|
+
end
|
154
|
+
|
155
|
+
CONTEXT_THREAD_KEY = :rollout_logging_context
|
156
|
+
WITHOUT_THREAD_KEY = :rollout_logging_disabled
|
157
|
+
|
158
|
+
def with_context(context)
|
159
|
+
raise ArgumentError, "context must be a Hash" unless context.is_a?(Hash)
|
160
|
+
raise ArgumentError, "block is required" unless block_given?
|
161
|
+
|
162
|
+
Thread.current[CONTEXT_THREAD_KEY] = context
|
163
|
+
yield
|
164
|
+
ensure
|
165
|
+
Thread.current[CONTEXT_THREAD_KEY] = nil
|
166
|
+
end
|
167
|
+
|
168
|
+
def current_context
|
169
|
+
Thread.current[CONTEXT_THREAD_KEY] || {}
|
170
|
+
end
|
171
|
+
|
172
|
+
def without
|
173
|
+
Thread.current[WITHOUT_THREAD_KEY] = true
|
174
|
+
yield
|
175
|
+
ensure
|
176
|
+
Thread.current[WITHOUT_THREAD_KEY] = nil
|
177
|
+
end
|
178
|
+
|
179
|
+
def logging_enabled?
|
180
|
+
!Thread.current[WITHOUT_THREAD_KEY]
|
181
|
+
end
|
182
|
+
|
183
|
+
private
|
184
|
+
|
185
|
+
def global_events_storage_key
|
186
|
+
"feature:_global_:logging:events"
|
187
|
+
end
|
188
|
+
|
189
|
+
def events_storage_key(feature_name)
|
190
|
+
"feature:#{feature_name}:logging:events"
|
191
|
+
end
|
192
|
+
|
193
|
+
def current_timestamp
|
194
|
+
(Time.now.to_f * 1_000_000).to_i
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
data/rollout.gemspec
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
$LOAD_PATH.push File.expand_path('lib', __dir__)
|
4
|
+
require 'rollout/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'rollout'
|
8
|
+
spec.version = Rollout::VERSION
|
9
|
+
spec.authors = ['James Golick']
|
10
|
+
spec.email = ['jamesgolick@gmail.com']
|
11
|
+
spec.description = 'Feature flippers with redis.'
|
12
|
+
spec.summary = 'Feature flippers with redis.'
|
13
|
+
spec.homepage = 'https://github.com/FetLife/rollout'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
spec.files = `git ls-files`.split("\n")
|
17
|
+
spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
18
|
+
spec.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
|
21
|
+
spec.required_ruby_version = '>= 2.3'
|
22
|
+
|
23
|
+
spec.add_dependency 'redis', '~> 4.0'
|
24
|
+
|
25
|
+
spec.add_development_dependency 'bundler', '>= 1.17'
|
26
|
+
spec.add_development_dependency 'pry'
|
27
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
28
|
+
spec.add_development_dependency 'rspec_junit_formatter', '~> 0.4'
|
29
|
+
spec.add_development_dependency 'rubocop', '~> 0.71'
|
30
|
+
spec.add_development_dependency 'simplecov', '0.17'
|
31
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
RSpec.describe 'Rollout::Logging' do
|
4
|
+
let(:rollout) { Rollout.new(Redis.current, logging: logging) }
|
5
|
+
let(:logging) { true }
|
6
|
+
let(:feature) { :foo }
|
7
|
+
|
8
|
+
it 'logs changes' do
|
9
|
+
expect(rollout.logging.last_event(feature)).to be_nil
|
10
|
+
|
11
|
+
rollout.activate_percentage(feature, 50)
|
12
|
+
|
13
|
+
expect(rollout.logging.updated_at(feature)).to_not be_nil
|
14
|
+
|
15
|
+
first_event = rollout.logging.last_event(feature)
|
16
|
+
|
17
|
+
expect(first_event.name).to eq 'update'
|
18
|
+
expect(first_event.data).to eq(before: { percentage: 0 }, after: { percentage: 50 })
|
19
|
+
|
20
|
+
rollout.activate_percentage(feature, 75)
|
21
|
+
|
22
|
+
second_event = rollout.logging.last_event(feature)
|
23
|
+
|
24
|
+
expect(second_event.name).to eq 'update'
|
25
|
+
expect(second_event.data).to eq(before: { percentage: 50 }, after: { percentage: 75 })
|
26
|
+
|
27
|
+
rollout.activate_group(feature, :hipsters)
|
28
|
+
|
29
|
+
third_event = rollout.logging.last_event(feature)
|
30
|
+
|
31
|
+
expect(third_event.name).to eq 'update'
|
32
|
+
expect(third_event.data).to eq(before: { groups: [] }, after: { groups: ['hipsters'] })
|
33
|
+
|
34
|
+
expect(rollout.logging.events(feature)).to eq [first_event, second_event, third_event]
|
35
|
+
end
|
36
|
+
|
37
|
+
context 'logging data changes' do
|
38
|
+
it 'logs changes' do
|
39
|
+
expect(rollout.logging.last_event(feature)).to be_nil
|
40
|
+
|
41
|
+
rollout.set_feature_data(feature, description: "foo")
|
42
|
+
|
43
|
+
event = rollout.logging.last_event(feature)
|
44
|
+
|
45
|
+
expect(event).not_to be_nil
|
46
|
+
expect(event.name).to eq 'update'
|
47
|
+
expect(event.data).to eq(before: { "data.description": nil }, after: { "data.description": "foo" })
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
context 'no logging' do
|
52
|
+
let(:logging) { nil }
|
53
|
+
|
54
|
+
it 'doesnt even respond to logging' do
|
55
|
+
expect(rollout).not_to respond_to :logging
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
context 'history truncation' do
|
60
|
+
let(:logging) { { history_length: 1 } }
|
61
|
+
|
62
|
+
it 'logs changes' do
|
63
|
+
expect(rollout.logging.last_event(feature)).to be_nil
|
64
|
+
|
65
|
+
rollout.activate_percentage(feature, 25)
|
66
|
+
|
67
|
+
first_event = rollout.logging.last_event(feature)
|
68
|
+
|
69
|
+
expect(first_event.name).to eq 'update'
|
70
|
+
expect(first_event.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
|
71
|
+
|
72
|
+
rollout.activate_percentage(feature, 30)
|
73
|
+
|
74
|
+
second_event = rollout.logging.last_event(feature)
|
75
|
+
|
76
|
+
expect(second_event.name).to eq 'update'
|
77
|
+
expect(second_event.data).to eq(before: { percentage: 25 }, after: { percentage: 30 })
|
78
|
+
|
79
|
+
expect(rollout.logging.events(feature)).to eq [second_event]
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
context 'with context' do
|
84
|
+
let(:current_user) { double(nickname: 'lester') }
|
85
|
+
|
86
|
+
it 'adds context to the event' do
|
87
|
+
rollout.logging.with_context(actor: current_user.nickname) do
|
88
|
+
rollout.activate_percentage(feature, 25)
|
89
|
+
end
|
90
|
+
|
91
|
+
event = rollout.logging.last_event(feature)
|
92
|
+
|
93
|
+
expect(event.name).to eq 'update'
|
94
|
+
expect(event.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
|
95
|
+
expect(event.context).to eq(actor: current_user.nickname)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
context 'global logs' do
|
100
|
+
let(:logging) { { global: true } }
|
101
|
+
let(:feature_foo) { 'foo' }
|
102
|
+
let(:feature_bar) { 'bar' }
|
103
|
+
|
104
|
+
it 'logs changes' do
|
105
|
+
expect(rollout.logging.last_event(feature_foo)).to be_nil
|
106
|
+
|
107
|
+
rollout.activate_percentage(feature_foo, 25)
|
108
|
+
|
109
|
+
event_foo = rollout.logging.last_event(feature_foo)
|
110
|
+
|
111
|
+
expect(event_foo.feature).to eq feature_foo
|
112
|
+
expect(event_foo.name).to eq 'update'
|
113
|
+
expect(event_foo.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
|
114
|
+
|
115
|
+
expect(rollout.logging.events(feature_foo)).to eq [event_foo]
|
116
|
+
|
117
|
+
rollout.activate_percentage(feature_bar, 30)
|
118
|
+
|
119
|
+
event_bar = rollout.logging.last_event(feature_bar)
|
120
|
+
|
121
|
+
expect(event_bar.feature).to eq feature_bar
|
122
|
+
expect(event_bar.name).to eq 'update'
|
123
|
+
expect(event_bar.data).to eq(before: { percentage: 0 }, after: { percentage: 30 })
|
124
|
+
|
125
|
+
expect(rollout.logging.events(feature_bar)).to eq [event_bar]
|
126
|
+
|
127
|
+
expect(rollout.logging.global_events).to eq [event_foo, event_bar]
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
context 'no logging for block' do
|
132
|
+
it 'doesnt log' do
|
133
|
+
rollout.logging.without do
|
134
|
+
rollout.activate_percentage(feature, 25)
|
135
|
+
end
|
136
|
+
|
137
|
+
event = rollout.logging.last_event(feature)
|
138
|
+
|
139
|
+
expect(event).to be_nil
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|