rollout 2.4.6 → 2.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3e6e9521ed03059b5b673616e985f89231d77d1ca42d8fa7ff00797ef906768
4
- data.tar.gz: ac66f6f5f0da7e03bbf506d09f62d34e6afd7f1ea09b248b5193d74f3b6a720e
3
+ metadata.gz: 37e56080ba30fb08aaa7e718e22fae3cab0745e84a91537e4e5520c2843a63a2
4
+ data.tar.gz: 2b2f8c5a6e7a3ddd8275d818b4a168e7573204c2e259e323ca7a2ea9996062d9
5
5
  SHA512:
6
- metadata.gz: ccebf1c2024c332cd921a49b400a7ee8e9a5c0ce95cdf59f5e89fb302bef08684cc04ffae30c952e14ffd5acc97d583871ee590aebe6aa2c519cc416afc17b9f
7
- data.tar.gz: fe0a400d1f694a6aefb5b1e7d89afb64a8cb28c07a1f510d01655d724bfb97d5b38e7c60e40669aec5fded5581e760549b53564078018cad92c968b6900753ee
6
+ metadata.gz: a31905463a16ca7413566460c9a693ab5b9063e74547c05aafe2cf40d5e8f8d83ecb9e85316dd48c3e6b5e5b63311ac222343b485228a695ff5b72f8b0d82a2c
7
+ data.tar.gz: 33fc542bdfe0def2b13a1b3697ba59c1e5f83e813a7c67740d94bf0a9725e17003fdc3fecfa923874883730dde589e0906b5d49ab19ff49c36943e259c28f134
@@ -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(','))
@@ -67,7 +67,8 @@ class Rollout
67
67
  {
68
68
  percentage: @percentage,
69
69
  groups: @groups,
70
- users: @users
70
+ users: @users,
71
+ data: @data,
71
72
  }
72
73
  end
73
74
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rollout
4
- VERSION = '2.4.6'
4
+ VERSION = '2.5.0'
5
5
  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
+
@@ -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.6
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-06-12 00:00:00.000000000 Z
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.0.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: []