rollout 2.4.6 → 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/lib/rollout.rb +31 -6
- data/lib/rollout/feature.rb +2 -1
- data/lib/rollout/logging.rb +198 -0
- data/lib/rollout/version.rb +1 -1
- data/spec/rollout/logging_spec.rb +143 -0
- data/spec/rollout_spec.rb +8 -4
- metadata +6 -6
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/lib/rollout.rb
CHANGED
@@ -1,18 +1,30 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'rollout/feature'
|
4
|
+
require 'rollout/logging'
|
4
5
|
require 'rollout/version'
|
5
6
|
require 'zlib'
|
6
7
|
require 'set'
|
7
8
|
require 'json'
|
9
|
+
require 'observer'
|
8
10
|
|
9
11
|
class Rollout
|
12
|
+
include Observable
|
13
|
+
|
10
14
|
RAND_BASE = (2**32 - 1) / 100.0
|
11
15
|
|
16
|
+
attr_reader :options, :storage
|
17
|
+
|
12
18
|
def initialize(storage, opts = {})
|
13
19
|
@storage = storage
|
14
20
|
@options = opts
|
15
21
|
@groups = { all: ->(_user) { true } }
|
22
|
+
|
23
|
+
extend(Logging) if opts[:logging]
|
24
|
+
end
|
25
|
+
|
26
|
+
def groups
|
27
|
+
@groups.keys
|
16
28
|
end
|
17
29
|
|
18
30
|
def activate(feature)
|
@@ -30,6 +42,10 @@ class Rollout
|
|
30
42
|
features.delete(feature.to_s)
|
31
43
|
@storage.set(features_key, features.join(','))
|
32
44
|
@storage.del(key(feature))
|
45
|
+
|
46
|
+
if respond_to?(:logging)
|
47
|
+
logging.delete(feature)
|
48
|
+
end
|
33
49
|
end
|
34
50
|
|
35
51
|
def set(feature, desired_state)
|
@@ -179,6 +195,21 @@ class Rollout
|
|
179
195
|
end
|
180
196
|
end
|
181
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
|
211
|
+
end
|
212
|
+
|
182
213
|
private
|
183
214
|
|
184
215
|
def key(name)
|
@@ -189,12 +220,6 @@ class Rollout
|
|
189
220
|
'feature:__features__'
|
190
221
|
end
|
191
222
|
|
192
|
-
def with_feature(feature)
|
193
|
-
f = get(feature)
|
194
|
-
yield(f)
|
195
|
-
save(f)
|
196
|
-
end
|
197
|
-
|
198
223
|
def save(feature)
|
199
224
|
@storage.set(key(feature.name), feature.serialize)
|
200
225
|
@storage.set(features_key, (features | [feature.name.to_sym]).join(','))
|
data/lib/rollout/feature.rb
CHANGED
@@ -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
@@ -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
@@ -429,7 +429,8 @@ RSpec.describe "Rollout" do
|
|
429
429
|
expect(feature.to_hash).to eq(
|
430
430
|
groups: [:caretakers, :greeters],
|
431
431
|
percentage: 10,
|
432
|
-
users: %w(42)
|
432
|
+
users: %w(42),
|
433
|
+
data: {},
|
433
434
|
)
|
434
435
|
|
435
436
|
feature = @rollout.get(:signup)
|
@@ -449,7 +450,8 @@ RSpec.describe "Rollout" do
|
|
449
450
|
expect(feature.to_hash).to eq(
|
450
451
|
groups: [:caretakers, :greeters].to_set,
|
451
452
|
percentage: 10,
|
452
|
-
users: %w(42).to_set
|
453
|
+
users: %w(42).to_set,
|
454
|
+
data: {},
|
453
455
|
)
|
454
456
|
|
455
457
|
feature = @rollout.get(:signup)
|
@@ -473,7 +475,8 @@ RSpec.describe "Rollout" do
|
|
473
475
|
expect(@rollout.get(feature).to_hash).to eq(
|
474
476
|
percentage: 0,
|
475
477
|
users: [],
|
476
|
-
groups: []
|
478
|
+
groups: [],
|
479
|
+
data: {},
|
477
480
|
)
|
478
481
|
end
|
479
482
|
end
|
@@ -485,7 +488,8 @@ RSpec.describe "Rollout" do
|
|
485
488
|
expect(@rollout.get(feature).to_hash).to eq(
|
486
489
|
percentage: 0,
|
487
490
|
users: Set.new,
|
488
|
-
groups: Set.new
|
491
|
+
groups: Set.new,
|
492
|
+
data: {},
|
489
493
|
)
|
490
494
|
end
|
491
495
|
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: 2020-
|
11
|
+
date: 2020-07-21 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: redis
|
@@ -126,8 +126,10 @@ files:
|
|
126
126
|
- Rakefile
|
127
127
|
- lib/rollout.rb
|
128
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: []
|