rollout 2.4.5 → 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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +18 -3
- data/README.md +8 -0
- data/lib/rollout.rb +38 -133
- data/lib/rollout/feature.rb +131 -0
- data/lib/rollout/logging.rb +198 -0
- data/lib/rollout/version.rb +1 -1
- data/rollout.gemspec +3 -3
- data/spec/rollout/logging_spec.rb +143 -0
- data/spec/rollout_spec.rb +9 -6
- data/spec/spec_helper.rb +9 -3
- metadata +14 -14
- data/Gemfile.lock +0 -65
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 37e56080ba30fb08aaa7e718e22fae3cab0745e84a91537e4e5520c2843a63a2
|
4
|
+
data.tar.gz: 2b2f8c5a6e7a3ddd8275d818b4a168e7573204c2e259e323ca7a2ea9996062d9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a31905463a16ca7413566460c9a693ab5b9063e74547c05aafe2cf40d5e8f8d83ecb9e85316dd48c3e6b5e5b63311ac222343b485228a695ff5b72f8b0d82a2c
|
7
|
+
data.tar.gz: 33fc542bdfe0def2b13a1b3697ba59c1e5f83e813a7c67740d94bf0a9725e17003fdc3fecfa923874883730dde589e0906b5d49ab19ff49c36943e259c28f134
|
data/.circleci/config.yml
CHANGED
@@ -3,24 +3,33 @@ version: 2.1
|
|
3
3
|
workflows:
|
4
4
|
main:
|
5
5
|
jobs:
|
6
|
+
- ruby27
|
6
7
|
- ruby26
|
7
8
|
- ruby25
|
8
9
|
- ruby24
|
9
10
|
- ruby23
|
10
11
|
|
11
12
|
executors:
|
13
|
+
ruby27:
|
14
|
+
docker:
|
15
|
+
- image: circleci/ruby:2.7
|
16
|
+
- image: circleci/redis:alpine
|
12
17
|
ruby26:
|
13
18
|
docker:
|
14
19
|
- image: circleci/ruby:2.6
|
20
|
+
- image: circleci/redis:alpine
|
15
21
|
ruby25:
|
16
22
|
docker:
|
17
23
|
- image: circleci/ruby:2.5
|
24
|
+
- image: circleci/redis:alpine
|
18
25
|
ruby24:
|
19
26
|
docker:
|
20
27
|
- image: circleci/ruby:2.4
|
28
|
+
- image: circleci/redis:alpine
|
21
29
|
ruby23:
|
22
30
|
docker:
|
23
31
|
- image: circleci/ruby:2.3
|
32
|
+
- image: circleci/redis:alpine
|
24
33
|
|
25
34
|
commands:
|
26
35
|
test:
|
@@ -31,7 +40,7 @@ commands:
|
|
31
40
|
|
32
41
|
- run:
|
33
42
|
name: Bundle Install
|
34
|
-
command: bundle check --path vendor/bundle || bundle install
|
43
|
+
command: bundle check --path vendor/bundle || bundle install
|
35
44
|
|
36
45
|
- save_cache:
|
37
46
|
key: bundler-{{ checksum "Gemfile.lock" }}
|
@@ -44,8 +53,8 @@ commands:
|
|
44
53
|
bundle exec rspec --format documentation --format RspecJunitFormatter --out test_results/rspec.xml
|
45
54
|
|
46
55
|
jobs:
|
47
|
-
|
48
|
-
executor:
|
56
|
+
ruby27:
|
57
|
+
executor: ruby27
|
49
58
|
steps:
|
50
59
|
- checkout
|
51
60
|
- test
|
@@ -61,6 +70,12 @@ jobs:
|
|
61
70
|
- store_test_results:
|
62
71
|
path: test_results
|
63
72
|
|
73
|
+
ruby26:
|
74
|
+
executor: ruby26
|
75
|
+
steps:
|
76
|
+
- checkout
|
77
|
+
- test
|
78
|
+
|
64
79
|
ruby25:
|
65
80
|
executor: ruby25
|
66
81
|
steps:
|
data/README.md
CHANGED
@@ -24,6 +24,14 @@ $redis = Redis.new
|
|
24
24
|
$rollout = Rollout.new($redis)
|
25
25
|
```
|
26
26
|
|
27
|
+
or even simpler
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
require 'redis'
|
31
|
+
$rollout = Rollout.new(Redis.current) # Will use REDIS_URL env var or default redis url
|
32
|
+
```
|
33
|
+
|
34
|
+
|
27
35
|
Update data specific to a feature:
|
28
36
|
|
29
37
|
```ruby
|
data/lib/rollout.rb
CHANGED
@@ -1,144 +1,30 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'rollout/feature'
|
4
|
+
require 'rollout/logging'
|
3
5
|
require 'rollout/version'
|
4
6
|
require 'zlib'
|
5
7
|
require 'set'
|
6
8
|
require 'json'
|
9
|
+
require 'observer'
|
7
10
|
|
8
11
|
class Rollout
|
9
|
-
|
10
|
-
|
11
|
-
class Feature
|
12
|
-
attr_accessor :groups, :users, :percentage, :data
|
13
|
-
attr_reader :name, :options
|
14
|
-
|
15
|
-
def initialize(name, string = nil, opts = {})
|
16
|
-
@options = opts
|
17
|
-
@name = name
|
18
|
-
|
19
|
-
if string
|
20
|
-
raw_percentage, raw_users, raw_groups, raw_data = string.split('|', 4)
|
21
|
-
@percentage = raw_percentage.to_f
|
22
|
-
@users = users_from_string(raw_users)
|
23
|
-
@groups = groups_from_string(raw_groups)
|
24
|
-
@data = raw_data.nil? || raw_data.strip.empty? ? {} : JSON.parse(raw_data)
|
25
|
-
else
|
26
|
-
clear
|
27
|
-
end
|
28
|
-
end
|
29
|
-
|
30
|
-
def serialize
|
31
|
-
"#{@percentage}|#{@users.to_a.join(',')}|#{@groups.to_a.join(',')}|#{serialize_data}"
|
32
|
-
end
|
33
|
-
|
34
|
-
def add_user(user)
|
35
|
-
id = user_id(user)
|
36
|
-
@users << id unless @users.include?(id)
|
37
|
-
end
|
38
|
-
|
39
|
-
def remove_user(user)
|
40
|
-
@users.delete(user_id(user))
|
41
|
-
end
|
42
|
-
|
43
|
-
def add_group(group)
|
44
|
-
@groups << group.to_sym unless @groups.include?(group.to_sym)
|
45
|
-
end
|
46
|
-
|
47
|
-
def remove_group(group)
|
48
|
-
@groups.delete(group.to_sym)
|
49
|
-
end
|
50
|
-
|
51
|
-
def clear
|
52
|
-
@groups = groups_from_string('')
|
53
|
-
@users = users_from_string('')
|
54
|
-
@percentage = 0
|
55
|
-
@data = {}
|
56
|
-
end
|
57
|
-
|
58
|
-
def active?(rollout, user)
|
59
|
-
if user
|
60
|
-
id = user_id(user)
|
61
|
-
user_in_percentage?(id) ||
|
62
|
-
user_in_active_users?(id) ||
|
63
|
-
user_in_active_group?(user, rollout)
|
64
|
-
else
|
65
|
-
@percentage == 100
|
66
|
-
end
|
67
|
-
end
|
68
|
-
|
69
|
-
def user_in_active_users?(user)
|
70
|
-
@users.include?(user_id(user))
|
71
|
-
end
|
72
|
-
|
73
|
-
def to_hash
|
74
|
-
{
|
75
|
-
percentage: @percentage,
|
76
|
-
groups: @groups,
|
77
|
-
users: @users
|
78
|
-
}
|
79
|
-
end
|
80
|
-
|
81
|
-
private
|
82
|
-
|
83
|
-
def user_id(user)
|
84
|
-
if user.is_a?(Integer) || user.is_a?(String)
|
85
|
-
user.to_s
|
86
|
-
else
|
87
|
-
user.send(id_user_by).to_s
|
88
|
-
end
|
89
|
-
end
|
90
|
-
|
91
|
-
def id_user_by
|
92
|
-
@options[:id_user_by] || :id
|
93
|
-
end
|
94
|
-
|
95
|
-
def user_in_percentage?(user)
|
96
|
-
Zlib.crc32(user_id_for_percentage(user)) < RAND_BASE * @percentage
|
97
|
-
end
|
98
|
-
|
99
|
-
def user_id_for_percentage(user)
|
100
|
-
if @options[:randomize_percentage]
|
101
|
-
user_id(user).to_s + @name.to_s
|
102
|
-
else
|
103
|
-
user_id(user)
|
104
|
-
end
|
105
|
-
end
|
106
|
-
|
107
|
-
def user_in_active_group?(user, rollout)
|
108
|
-
@groups.any? do |g|
|
109
|
-
rollout.active_in_group?(g, user)
|
110
|
-
end
|
111
|
-
end
|
112
|
-
|
113
|
-
def serialize_data
|
114
|
-
return '' unless @data.is_a? Hash
|
12
|
+
include Observable
|
115
13
|
|
116
|
-
|
117
|
-
end
|
14
|
+
RAND_BASE = (2**32 - 1) / 100.0
|
118
15
|
|
119
|
-
|
120
|
-
users = (raw_users || '').split(',').map(&:to_s)
|
121
|
-
if @options[:use_sets]
|
122
|
-
users.to_set
|
123
|
-
else
|
124
|
-
users
|
125
|
-
end
|
126
|
-
end
|
127
|
-
|
128
|
-
def groups_from_string(raw_groups)
|
129
|
-
groups = (raw_groups || '').split(',').map(&:to_sym)
|
130
|
-
if @options[:use_sets]
|
131
|
-
groups.to_set
|
132
|
-
else
|
133
|
-
groups
|
134
|
-
end
|
135
|
-
end
|
136
|
-
end
|
16
|
+
attr_reader :options, :storage
|
137
17
|
|
138
18
|
def initialize(storage, opts = {})
|
139
19
|
@storage = storage
|
140
20
|
@options = opts
|
141
21
|
@groups = { all: ->(_user) { true } }
|
22
|
+
|
23
|
+
extend(Logging) if opts[:logging]
|
24
|
+
end
|
25
|
+
|
26
|
+
def groups
|
27
|
+
@groups.keys
|
142
28
|
end
|
143
29
|
|
144
30
|
def activate(feature)
|
@@ -156,6 +42,10 @@ class Rollout
|
|
156
42
|
features.delete(feature.to_s)
|
157
43
|
@storage.set(features_key, features.join(','))
|
158
44
|
@storage.del(key(feature))
|
45
|
+
|
46
|
+
if respond_to?(:logging)
|
47
|
+
logging.delete(feature)
|
48
|
+
end
|
159
49
|
end
|
160
50
|
|
161
51
|
def set(feature, desired_state)
|
@@ -296,7 +186,28 @@ class Rollout
|
|
296
186
|
end
|
297
187
|
|
298
188
|
def exists?(feature)
|
299
|
-
|
189
|
+
# since redis-rb v4.2, `#exists?` replaces `#exists` which now returns integer value instead of boolean
|
190
|
+
# https://github.com/redis/redis-rb/pull/918
|
191
|
+
if @storage.respond_to?(:exists?)
|
192
|
+
@storage.exists?(key(feature))
|
193
|
+
else
|
194
|
+
@storage.exists(key(feature))
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def with_feature(feature)
|
199
|
+
f = get(feature)
|
200
|
+
|
201
|
+
if count_observers > 0
|
202
|
+
before = Marshal.load(Marshal.dump(f))
|
203
|
+
yield(f)
|
204
|
+
save(f)
|
205
|
+
changed
|
206
|
+
notify_observers(:update, before, f)
|
207
|
+
else
|
208
|
+
yield(f)
|
209
|
+
save(f)
|
210
|
+
end
|
300
211
|
end
|
301
212
|
|
302
213
|
private
|
@@ -309,12 +220,6 @@ class Rollout
|
|
309
220
|
'feature:__features__'
|
310
221
|
end
|
311
222
|
|
312
|
-
def with_feature(feature)
|
313
|
-
f = get(feature)
|
314
|
-
yield(f)
|
315
|
-
save(f)
|
316
|
-
end
|
317
|
-
|
318
223
|
def save(feature)
|
319
224
|
@storage.set(key(feature.name), feature.serialize)
|
320
225
|
@storage.set(features_key, (features | [feature.name.to_sym]).join(','))
|
@@ -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/lib/rollout/version.rb
CHANGED
data/rollout.gemspec
CHANGED
@@ -22,10 +22,10 @@ Gem::Specification.new do |spec|
|
|
22
22
|
|
23
23
|
spec.add_dependency 'redis', '~> 4.0'
|
24
24
|
|
25
|
-
spec.add_development_dependency 'bundler', '
|
26
|
-
spec.add_development_dependency '
|
25
|
+
spec.add_development_dependency 'bundler', '>= 1.17'
|
26
|
+
spec.add_development_dependency 'pry'
|
27
27
|
spec.add_development_dependency 'rspec', '~> 3.0'
|
28
28
|
spec.add_development_dependency 'rspec_junit_formatter', '~> 0.4'
|
29
29
|
spec.add_development_dependency 'rubocop', '~> 0.71'
|
30
|
-
spec.add_development_dependency 'simplecov', '
|
30
|
+
spec.add_development_dependency 'simplecov', '0.17'
|
31
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
|
+
|
data/spec/rollout_spec.rb
CHANGED
@@ -2,8 +2,7 @@ require "spec_helper"
|
|
2
2
|
|
3
3
|
RSpec.describe "Rollout" do
|
4
4
|
before do
|
5
|
-
@
|
6
|
-
@rollout = Rollout.new(@redis)
|
5
|
+
@rollout = Rollout.new(Redis.current)
|
7
6
|
end
|
8
7
|
|
9
8
|
describe "when a group is activated" do
|
@@ -430,7 +429,8 @@ RSpec.describe "Rollout" do
|
|
430
429
|
expect(feature.to_hash).to eq(
|
431
430
|
groups: [:caretakers, :greeters],
|
432
431
|
percentage: 10,
|
433
|
-
users: %w(42)
|
432
|
+
users: %w(42),
|
433
|
+
data: {},
|
434
434
|
)
|
435
435
|
|
436
436
|
feature = @rollout.get(:signup)
|
@@ -450,7 +450,8 @@ RSpec.describe "Rollout" do
|
|
450
450
|
expect(feature.to_hash).to eq(
|
451
451
|
groups: [:caretakers, :greeters].to_set,
|
452
452
|
percentage: 10,
|
453
|
-
users: %w(42).to_set
|
453
|
+
users: %w(42).to_set,
|
454
|
+
data: {},
|
454
455
|
)
|
455
456
|
|
456
457
|
feature = @rollout.get(:signup)
|
@@ -474,7 +475,8 @@ RSpec.describe "Rollout" do
|
|
474
475
|
expect(@rollout.get(feature).to_hash).to eq(
|
475
476
|
percentage: 0,
|
476
477
|
users: [],
|
477
|
-
groups: []
|
478
|
+
groups: [],
|
479
|
+
data: {},
|
478
480
|
)
|
479
481
|
end
|
480
482
|
end
|
@@ -486,7 +488,8 @@ RSpec.describe "Rollout" do
|
|
486
488
|
expect(@rollout.get(feature).to_hash).to eq(
|
487
489
|
percentage: 0,
|
488
490
|
users: Set.new,
|
489
|
-
groups: Set.new
|
491
|
+
groups: Set.new,
|
492
|
+
data: {},
|
490
493
|
)
|
491
494
|
end
|
492
495
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -5,8 +5,14 @@ require 'simplecov'
|
|
5
5
|
SimpleCov.start
|
6
6
|
|
7
7
|
require 'bundler/setup'
|
8
|
-
require
|
9
|
-
require
|
8
|
+
require 'redis'
|
9
|
+
require 'rollout'
|
10
|
+
|
11
|
+
Redis.current = Redis.new(
|
12
|
+
host: ENV.fetch('REDIS_HOST', '127.0.0.1'),
|
13
|
+
port: ENV.fetch('REDIS_PORT', '6379'),
|
14
|
+
db: ENV.fetch('REDIS_DB', '7'),
|
15
|
+
)
|
10
16
|
|
11
17
|
RSpec.configure do |config|
|
12
18
|
config.example_status_persistence_file_path = '.rspec_status'
|
@@ -17,5 +23,5 @@ RSpec.configure do |config|
|
|
17
23
|
c.syntax = :expect
|
18
24
|
end
|
19
25
|
|
20
|
-
config.before { Redis.
|
26
|
+
config.before { Redis.current.flushdb }
|
21
27
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rollout
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- James Golick
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2020-07-21 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: redis
|
@@ -28,18 +28,18 @@ dependencies:
|
|
28
28
|
name: bundler
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
|
-
- - "
|
31
|
+
- - ">="
|
32
32
|
- !ruby/object:Gem::Version
|
33
33
|
version: '1.17'
|
34
34
|
type: :development
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
|
-
- - "
|
38
|
+
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '1.17'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
|
-
name:
|
42
|
+
name: pry
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
44
44
|
requirements:
|
45
45
|
- - ">="
|
@@ -98,16 +98,16 @@ dependencies:
|
|
98
98
|
name: simplecov
|
99
99
|
requirement: !ruby/object:Gem::Requirement
|
100
100
|
requirements:
|
101
|
-
- -
|
101
|
+
- - '='
|
102
102
|
- !ruby/object:Gem::Version
|
103
|
-
version: '0.
|
103
|
+
version: '0.17'
|
104
104
|
type: :development
|
105
105
|
prerelease: false
|
106
106
|
version_requirements: !ruby/object:Gem::Requirement
|
107
107
|
requirements:
|
108
|
-
- -
|
108
|
+
- - '='
|
109
109
|
- !ruby/object:Gem::Version
|
110
|
-
version: '0.
|
110
|
+
version: '0.17'
|
111
111
|
description: Feature flippers with redis.
|
112
112
|
email:
|
113
113
|
- jamesgolick@gmail.com
|
@@ -121,13 +121,15 @@ files:
|
|
121
121
|
- ".rubocop.yml"
|
122
122
|
- ".travis.yml"
|
123
123
|
- Gemfile
|
124
|
-
- Gemfile.lock
|
125
124
|
- LICENSE
|
126
125
|
- README.md
|
127
126
|
- Rakefile
|
128
127
|
- lib/rollout.rb
|
128
|
+
- lib/rollout/feature.rb
|
129
|
+
- lib/rollout/logging.rb
|
129
130
|
- lib/rollout/version.rb
|
130
131
|
- rollout.gemspec
|
132
|
+
- spec/rollout/logging_spec.rb
|
131
133
|
- spec/rollout_spec.rb
|
132
134
|
- spec/spec_helper.rb
|
133
135
|
homepage: https://github.com/FetLife/rollout
|
@@ -149,10 +151,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
149
151
|
- !ruby/object:Gem::Version
|
150
152
|
version: '0'
|
151
153
|
requirements: []
|
152
|
-
rubygems_version: 3.
|
154
|
+
rubygems_version: 3.1.2
|
153
155
|
signing_key:
|
154
156
|
specification_version: 4
|
155
157
|
summary: Feature flippers with redis.
|
156
|
-
test_files:
|
157
|
-
- spec/rollout_spec.rb
|
158
|
-
- spec/spec_helper.rb
|
158
|
+
test_files: []
|
data/Gemfile.lock
DELETED
@@ -1,65 +0,0 @@
|
|
1
|
-
PATH
|
2
|
-
remote: .
|
3
|
-
specs:
|
4
|
-
rollout (2.4.5)
|
5
|
-
redis (~> 4.0)
|
6
|
-
|
7
|
-
GEM
|
8
|
-
remote: https://rubygems.org/
|
9
|
-
specs:
|
10
|
-
ast (2.4.0)
|
11
|
-
diff-lcs (1.3)
|
12
|
-
docile (1.3.2)
|
13
|
-
fakeredis (0.7.0)
|
14
|
-
redis (>= 3.2, < 5.0)
|
15
|
-
jaro_winkler (1.5.3)
|
16
|
-
json (2.2.0)
|
17
|
-
parallel (1.17.0)
|
18
|
-
parser (2.6.3.0)
|
19
|
-
ast (~> 2.4.0)
|
20
|
-
rainbow (3.0.0)
|
21
|
-
redis (4.1.2)
|
22
|
-
rspec (3.8.0)
|
23
|
-
rspec-core (~> 3.8.0)
|
24
|
-
rspec-expectations (~> 3.8.0)
|
25
|
-
rspec-mocks (~> 3.8.0)
|
26
|
-
rspec-core (3.8.2)
|
27
|
-
rspec-support (~> 3.8.0)
|
28
|
-
rspec-expectations (3.8.4)
|
29
|
-
diff-lcs (>= 1.2.0, < 2.0)
|
30
|
-
rspec-support (~> 3.8.0)
|
31
|
-
rspec-mocks (3.8.1)
|
32
|
-
diff-lcs (>= 1.2.0, < 2.0)
|
33
|
-
rspec-support (~> 3.8.0)
|
34
|
-
rspec-support (3.8.2)
|
35
|
-
rspec_junit_formatter (0.4.1)
|
36
|
-
rspec-core (>= 2, < 4, != 2.12.0)
|
37
|
-
rubocop (0.72.0)
|
38
|
-
jaro_winkler (~> 1.5.1)
|
39
|
-
parallel (~> 1.10)
|
40
|
-
parser (>= 2.6)
|
41
|
-
rainbow (>= 2.2.2, < 4.0)
|
42
|
-
ruby-progressbar (~> 1.7)
|
43
|
-
unicode-display_width (>= 1.4.0, < 1.7)
|
44
|
-
ruby-progressbar (1.10.1)
|
45
|
-
simplecov (0.16.1)
|
46
|
-
docile (~> 1.1)
|
47
|
-
json (>= 1.8, < 3)
|
48
|
-
simplecov-html (~> 0.10.0)
|
49
|
-
simplecov-html (0.10.2)
|
50
|
-
unicode-display_width (1.6.0)
|
51
|
-
|
52
|
-
PLATFORMS
|
53
|
-
ruby
|
54
|
-
|
55
|
-
DEPENDENCIES
|
56
|
-
bundler (~> 1.17)
|
57
|
-
fakeredis
|
58
|
-
rollout!
|
59
|
-
rspec (~> 3.0)
|
60
|
-
rspec_junit_formatter (~> 0.4)
|
61
|
-
rubocop (~> 0.71)
|
62
|
-
simplecov (~> 0.16)
|
63
|
-
|
64
|
-
BUNDLED WITH
|
65
|
-
1.17.2
|