rollout 2.5.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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rollout
4
+ VERSION = '2.5.0'
5
+ end
@@ -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
+