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 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: []