onni 0.1.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 +7 -0
- data/CHANGELOG.md +30 -0
- data/CODE_OF_CONDUCT.md +26 -0
- data/CONTRIBUTING.md +23 -0
- data/GOVERNANCE.md +25 -0
- data/LICENSE.md +21 -0
- data/README.md +301 -0
- data/SECURITY.md +25 -0
- data/app/admin/features.rb +97 -0
- data/app/models/onni/feature.rb +14 -0
- data/app/models/onni/gate.rb +12 -0
- data/app/models/onni/hit_chart.rb +82 -0
- data/app/models/onni/stat_bucket.rb +12 -0
- data/app/views/admin/features/_chart.html.erb +24 -0
- data/app/views/admin/features/_stats.html.erb +49 -0
- data/app/views/admin/features/show.html.erb +60 -0
- data/config/locales/en.yml +6 -0
- data/config/polyrun_coverage.yml +8 -0
- data/lib/generators/onni/install_generator.rb +37 -0
- data/lib/generators/onni/stats_cleanup_generator.rb +50 -0
- data/lib/generators/onni/templates/add_onni_stats_period_start_index.rb.tt +9 -0
- data/lib/generators/onni/templates/create_onni_features.rb.tt +34 -0
- data/lib/generators/onni/templates/features.yml.tt +9 -0
- data/lib/generators/onni/templates/initializer.rb.tt +41 -0
- data/lib/generators/onni/templates/purge_stats_job.rb.tt +26 -0
- data/lib/onni/active_admin.rb +13 -0
- data/lib/onni/actor.rb +31 -0
- data/lib/onni/adapters/active_record.rb +128 -0
- data/lib/onni/adapters/memory.rb +161 -0
- data/lib/onni/conditions.rb +30 -0
- data/lib/onni/configuration.rb +54 -0
- data/lib/onni/engine.rb +50 -0
- data/lib/onni/flag.rb +53 -0
- data/lib/onni/group.rb +36 -0
- data/lib/onni/instrumenter.rb +27 -0
- data/lib/onni/migrator/importer.rb +55 -0
- data/lib/onni/migrator/snapshot.rb +15 -0
- data/lib/onni/registry.rb +78 -0
- data/lib/onni/request_store.rb +33 -0
- data/lib/onni/stats/buffer.rb +35 -0
- data/lib/onni/stats/overview.rb +34 -0
- data/lib/onni/stats/purge.rb +77 -0
- data/lib/onni/stats/sampler.rb +62 -0
- data/lib/onni/stats.rb +45 -0
- data/lib/onni/store.rb +155 -0
- data/lib/onni/sync.rb +43 -0
- data/lib/onni/version.rb +5 -0
- data/lib/onni.rb +193 -0
- data/onni.gemspec +72 -0
- data/sig/onni.rbs +210 -0
- metadata +373 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onni
|
|
4
|
+
module Stats
|
|
5
|
+
# Deletes hourly hit buckets older than a cutoff, in id-ordered batches.
|
|
6
|
+
# With enqueue, one batch runs then the next page is scheduled.
|
|
7
|
+
# Without enqueue (console `Purge.call`), remaining pages drain in-process.
|
|
8
|
+
class Purge
|
|
9
|
+
DEFAULT_BATCH_SIZE = 1_000
|
|
10
|
+
|
|
11
|
+
def self.call(**kwargs)
|
|
12
|
+
new(**kwargs).call
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(older_than: nil, batch_size: DEFAULT_BATCH_SIZE, after_id: 0, processed_count: 0, enqueue: nil)
|
|
16
|
+
@older_than = older_than
|
|
17
|
+
@batch_size = batch_size
|
|
18
|
+
@after_id = after_id
|
|
19
|
+
@processed_count = processed_count
|
|
20
|
+
@enqueue = enqueue
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
older_than = resolve_older_than
|
|
25
|
+
processed = @processed_count
|
|
26
|
+
after_id = @after_id
|
|
27
|
+
|
|
28
|
+
loop do
|
|
29
|
+
batch = Onni.adapter.purge_stats_batch(
|
|
30
|
+
older_than: older_than,
|
|
31
|
+
batch_size: @batch_size,
|
|
32
|
+
after_id: after_id
|
|
33
|
+
)
|
|
34
|
+
processed += batch[:deleted]
|
|
35
|
+
break if batch[:last_id].nil?
|
|
36
|
+
|
|
37
|
+
if @enqueue
|
|
38
|
+
@enqueue.call(
|
|
39
|
+
older_than: older_than,
|
|
40
|
+
batch_size: @batch_size,
|
|
41
|
+
after_id: batch[:last_id],
|
|
42
|
+
processed_count: processed
|
|
43
|
+
)
|
|
44
|
+
return {
|
|
45
|
+
deleted: processed - @processed_count,
|
|
46
|
+
processed_count: processed,
|
|
47
|
+
after_id: batch[:last_id],
|
|
48
|
+
continued: true
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
after_id = batch[:last_id]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
deleted: processed - @processed_count,
|
|
57
|
+
processed_count: processed,
|
|
58
|
+
after_id: after_id,
|
|
59
|
+
continued: false
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def resolve_older_than
|
|
66
|
+
return @older_than if @older_than
|
|
67
|
+
|
|
68
|
+
retention = Onni.configuration.stats_retention
|
|
69
|
+
if retention.nil?
|
|
70
|
+
raise ArgumentError, "Pass older_than: or set Onni.configuration.stats_retention"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
retention.ago
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onni
|
|
4
|
+
module Stats
|
|
5
|
+
# Sampling decision for hit stats:
|
|
6
|
+
# - float rate in 0.0..1.0 with clamp
|
|
7
|
+
# - skip Random when rate is 1.0
|
|
8
|
+
# - optional per-evaluation sampler callable
|
|
9
|
+
# - ignore list for noisy keys
|
|
10
|
+
# - optional inverse-rate weight so stored counters estimate full traffic
|
|
11
|
+
module Sampler
|
|
12
|
+
MAX_WEIGHT = 1_000_000
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def decide(name:, enabled:)
|
|
17
|
+
config = Onni.configuration
|
|
18
|
+
return :skip unless config.stats_enabled
|
|
19
|
+
return :skip if ignored?(name, config)
|
|
20
|
+
|
|
21
|
+
rate = coerce_rate(rate_for(name: name, enabled: enabled, config: config))
|
|
22
|
+
return :skip if rate <= 0.0
|
|
23
|
+
return {record: true, weight: 1} if rate >= 1.0
|
|
24
|
+
return :skip unless Random.rand < rate
|
|
25
|
+
|
|
26
|
+
weight = config.stats_scale_sampled_hits ? weight_for(rate) : 1
|
|
27
|
+
{record: true, weight: weight}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def coerce_rate(rate)
|
|
31
|
+
case rate
|
|
32
|
+
when true then 1.0
|
|
33
|
+
when false, nil then 0.0
|
|
34
|
+
else
|
|
35
|
+
value = rate.to_f
|
|
36
|
+
value.clamp(0.0, 1.0)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def weight_for(rate)
|
|
41
|
+
return 1 if rate <= 0.0
|
|
42
|
+
|
|
43
|
+
(1.0 / rate).round.clamp(1, MAX_WEIGHT)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def rate_for(name:, enabled:, config:)
|
|
47
|
+
if config.stats_sampler
|
|
48
|
+
sampled = config.stats_sampler.call({name: name.to_s, enabled: enabled})
|
|
49
|
+
return sampled unless sampled.nil?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
config.stats_sample_rate
|
|
53
|
+
end
|
|
54
|
+
private_class_method :rate_for
|
|
55
|
+
|
|
56
|
+
def ignored?(name, config)
|
|
57
|
+
Array(config.stats_ignore).any? { |key| key.to_s == name.to_s }
|
|
58
|
+
end
|
|
59
|
+
private_class_method :ignored?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/onni/stats.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "onni/stats/purge"
|
|
4
|
+
require "onni/stats/sampler"
|
|
5
|
+
require "onni/stats/overview"
|
|
6
|
+
require "onni/stats/buffer"
|
|
7
|
+
|
|
8
|
+
module Onni
|
|
9
|
+
module Stats
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def record(name, enabled:)
|
|
13
|
+
decision = Sampler.decide(name: name, enabled: enabled)
|
|
14
|
+
return if decision == :skip
|
|
15
|
+
|
|
16
|
+
Instrumenter.instrument("stats.record", name: name.to_s, enabled: enabled, weight: decision[:weight]) do
|
|
17
|
+
if Onni.configuration.stats_buffer
|
|
18
|
+
Buffer.add(name, enabled: enabled, weight: decision[:weight])
|
|
19
|
+
else
|
|
20
|
+
Onni.adapter.record_stat(name, enabled: enabled, weight: decision[:weight])
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
rescue => error
|
|
24
|
+
Rails.logger.warn("Onni stats failed: #{error.class}: #{error.message}") if defined?(Rails)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def for(name, period: "hour", from: 24.hours.ago, to: Time.current)
|
|
28
|
+
Onni.adapter.stats_for(name, period: period, from: from, to: to)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def purge!(**kwargs)
|
|
32
|
+
Instrumenter.instrument("stats.purge") do |payload|
|
|
33
|
+
result = Purge.call(**kwargs)
|
|
34
|
+
payload[:deleted] = result[:deleted]
|
|
35
|
+
payload[:processed_count] = result[:processed_count]
|
|
36
|
+
payload[:continued] = result[:continued]
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def overview
|
|
42
|
+
Overview.call
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/onni/store.rb
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Onni
|
|
7
|
+
module Store
|
|
8
|
+
CACHE_KEY_PREFIX = "onni/gates/"
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def enabled?(name, actor = nil, record_stats: true)
|
|
13
|
+
return false unless Registry.known?(name)
|
|
14
|
+
|
|
15
|
+
result = evaluate(name, actor)
|
|
16
|
+
Stats.record(name, enabled: result) if record_stats
|
|
17
|
+
result
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def variant(name, actor, record_stats: true)
|
|
21
|
+
return unless Registry.known?(name)
|
|
22
|
+
return if actor.nil?
|
|
23
|
+
|
|
24
|
+
definition = Registry.definition(name) || {}
|
|
25
|
+
return if expired?(definition)
|
|
26
|
+
|
|
27
|
+
gates = cached_gates(name)
|
|
28
|
+
return if gates.empty?
|
|
29
|
+
|
|
30
|
+
matched =
|
|
31
|
+
boolean_enabled?(gates) ||
|
|
32
|
+
actor_enabled?(gates, actor) ||
|
|
33
|
+
group_enabled?(gates, actor) ||
|
|
34
|
+
percentage_enabled?(gates, name, actor)
|
|
35
|
+
return unless matched
|
|
36
|
+
return unless Conditions.met?(name, actor, definition: definition)
|
|
37
|
+
|
|
38
|
+
Stats.record(name, enabled: true) if record_stats
|
|
39
|
+
|
|
40
|
+
variants = variant_weights(definition, gates)
|
|
41
|
+
return if variants.blank?
|
|
42
|
+
|
|
43
|
+
Actor.variant_for(actor, name, variants)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def evaluate(name, actor)
|
|
47
|
+
definition = Registry.definition(name) || {}
|
|
48
|
+
return false if expired?(definition)
|
|
49
|
+
|
|
50
|
+
gates = cached_gates(name)
|
|
51
|
+
return false if gates.empty?
|
|
52
|
+
|
|
53
|
+
matched =
|
|
54
|
+
boolean_enabled?(gates) ||
|
|
55
|
+
actor_enabled?(gates, actor) ||
|
|
56
|
+
group_enabled?(gates, actor) ||
|
|
57
|
+
percentage_enabled?(gates, name, actor)
|
|
58
|
+
return false unless matched
|
|
59
|
+
|
|
60
|
+
Conditions.met?(name, actor, definition: definition)
|
|
61
|
+
end
|
|
62
|
+
private_class_method :evaluate
|
|
63
|
+
|
|
64
|
+
def variant_weights(definition, gates)
|
|
65
|
+
raw = definition["variants"]
|
|
66
|
+
return normalize_variants(raw) if raw.present?
|
|
67
|
+
|
|
68
|
+
gate = Array(gates).find { |entry| entry["key"] == "variants" }
|
|
69
|
+
return {} unless gate
|
|
70
|
+
|
|
71
|
+
normalize_variants(JSON.parse(gate["value"]))
|
|
72
|
+
rescue JSON::ParserError
|
|
73
|
+
{}
|
|
74
|
+
end
|
|
75
|
+
private_class_method :variant_weights
|
|
76
|
+
|
|
77
|
+
def normalize_variants(raw)
|
|
78
|
+
raw.transform_keys(&:to_s).transform_values(&:to_i)
|
|
79
|
+
end
|
|
80
|
+
private_class_method :normalize_variants
|
|
81
|
+
|
|
82
|
+
def expired?(definition)
|
|
83
|
+
raw = definition["expires_at"]
|
|
84
|
+
return false if raw.nil? || raw.to_s.empty?
|
|
85
|
+
|
|
86
|
+
deadline = definition["expires_at_time"]
|
|
87
|
+
if deadline.nil?
|
|
88
|
+
begin
|
|
89
|
+
deadline = Time.parse(raw.to_s).utc
|
|
90
|
+
rescue ArgumentError, TypeError
|
|
91
|
+
return true
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
deadline < Time.now.utc
|
|
95
|
+
rescue ArgumentError, TypeError
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
private_class_method :expired?
|
|
99
|
+
|
|
100
|
+
def cached_gates(name)
|
|
101
|
+
key = name.to_s
|
|
102
|
+
preloaded = RequestStore.gates
|
|
103
|
+
return preloaded[key] if preloaded&.key?(key)
|
|
104
|
+
|
|
105
|
+
cache = Onni.configuration.cache
|
|
106
|
+
return Onni.adapter.gates_for(key) unless cache
|
|
107
|
+
|
|
108
|
+
cache.fetch(cache_key(key), expires_in: Onni.configuration.cache_ttl) do
|
|
109
|
+
Onni.adapter.gates_for(key)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
private_class_method :cached_gates
|
|
113
|
+
|
|
114
|
+
def bust_cache!(name)
|
|
115
|
+
key = name.to_s
|
|
116
|
+
Onni.configuration.cache&.delete(cache_key(key))
|
|
117
|
+
RequestStore.gates&.delete(key)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def cache_key(name)
|
|
121
|
+
"#{CACHE_KEY_PREFIX}#{Onni.adapter.object_id}/#{name}"
|
|
122
|
+
end
|
|
123
|
+
private_class_method :cache_key
|
|
124
|
+
|
|
125
|
+
def boolean_enabled?(gates)
|
|
126
|
+
gates.any? { |gate| gate["key"] == "boolean" && gate["value"] == "true" }
|
|
127
|
+
end
|
|
128
|
+
private_class_method :boolean_enabled?
|
|
129
|
+
|
|
130
|
+
def actor_enabled?(gates, actor)
|
|
131
|
+
actor_id = Actor.id(actor)
|
|
132
|
+
return false if actor_id.blank?
|
|
133
|
+
|
|
134
|
+
gates.any? { |gate| gate["key"] == "actors" && gate["value"] == actor_id }
|
|
135
|
+
end
|
|
136
|
+
private_class_method :actor_enabled?
|
|
137
|
+
|
|
138
|
+
def group_enabled?(gates, actor)
|
|
139
|
+
return false if actor.nil?
|
|
140
|
+
|
|
141
|
+
gates.any? { |gate| gate["key"] == "groups" && Group.member?(gate["value"], actor) }
|
|
142
|
+
end
|
|
143
|
+
private_class_method :group_enabled?
|
|
144
|
+
|
|
145
|
+
def percentage_enabled?(gates, name, actor)
|
|
146
|
+
return false if actor.nil?
|
|
147
|
+
|
|
148
|
+
gate = gates.find { |entry| entry["key"] == "percentage_of_actors" }
|
|
149
|
+
return false unless gate
|
|
150
|
+
|
|
151
|
+
Actor.percentage_value(actor, name) < gate["value"].to_i
|
|
152
|
+
end
|
|
153
|
+
private_class_method :percentage_enabled?
|
|
154
|
+
end
|
|
155
|
+
end
|
data/lib/onni/sync.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onni
|
|
4
|
+
# Configurable mutual exclusion for shared in-process state.
|
|
5
|
+
# On Ruby 3.4+, Mutex ownership is per Fiber, so :mutex covers threads and fibers.
|
|
6
|
+
# Do not Fiber.yield inside a synchronize block.
|
|
7
|
+
module Sync
|
|
8
|
+
BOOTSTRAP = Mutex.new
|
|
9
|
+
|
|
10
|
+
class NullLock
|
|
11
|
+
def synchronize
|
|
12
|
+
yield
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def new_lock
|
|
19
|
+
case Onni.configuration.concurrency
|
|
20
|
+
when :mutex then Mutex.new
|
|
21
|
+
when :none then NullLock.new
|
|
22
|
+
else
|
|
23
|
+
raise ArgumentError, "Unknown Onni concurrency #{Onni.configuration.concurrency.inspect}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def synchronize(&block)
|
|
28
|
+
shared_lock.synchronize(&block)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset!
|
|
32
|
+
BOOTSTRAP.synchronize { @shared_lock = nil }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def shared_lock
|
|
36
|
+
lock = @shared_lock
|
|
37
|
+
return lock if lock
|
|
38
|
+
|
|
39
|
+
BOOTSTRAP.synchronize { @shared_lock ||= new_lock }
|
|
40
|
+
end
|
|
41
|
+
private_class_method :shared_lock
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/onni/version.rb
ADDED
data/lib/onni.rb
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "json"
|
|
5
|
+
require "onni/version"
|
|
6
|
+
require "onni/configuration"
|
|
7
|
+
require "onni/sync"
|
|
8
|
+
require "onni/instrumenter"
|
|
9
|
+
require "onni/registry"
|
|
10
|
+
require "onni/actor"
|
|
11
|
+
require "onni/group"
|
|
12
|
+
require "onni/conditions"
|
|
13
|
+
require "onni/stats"
|
|
14
|
+
require "onni/flag"
|
|
15
|
+
require "onni/request_store"
|
|
16
|
+
require "onni/store"
|
|
17
|
+
require "onni/adapters/memory"
|
|
18
|
+
require "onni/adapters/active_record"
|
|
19
|
+
require "onni/migrator/snapshot"
|
|
20
|
+
require "onni/migrator/importer"
|
|
21
|
+
require "onni/engine" if defined?(Rails::Engine)
|
|
22
|
+
|
|
23
|
+
module Onni
|
|
24
|
+
class << self
|
|
25
|
+
def configuration
|
|
26
|
+
@configuration ||= Configuration.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def configure
|
|
30
|
+
yield configuration
|
|
31
|
+
Sync.reset!
|
|
32
|
+
@adapter = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def adapter
|
|
36
|
+
@adapter ||= build_adapter
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_writer :adapter
|
|
40
|
+
|
|
41
|
+
def reset_adapter!
|
|
42
|
+
@adapter = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def enabled?(name, actor = nil, record_stats: true)
|
|
46
|
+
Store.enabled?(name, actor, record_stats: record_stats)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def disabled?(name, actor = nil, record_stats: true)
|
|
50
|
+
!enabled?(name, actor, record_stats: record_stats)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def variant(name, actor = nil, record_stats: true)
|
|
54
|
+
Store.variant(name, actor, record_stats: record_stats)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def enable(name, actor = nil)
|
|
58
|
+
feature_operation(name, :enable) do |payload|
|
|
59
|
+
add(name)
|
|
60
|
+
if actor
|
|
61
|
+
adapter.add_gate(name, "actors", Actor.id(actor))
|
|
62
|
+
payload[:actor_id] = Actor.id(actor)
|
|
63
|
+
else
|
|
64
|
+
adapter.add_gate(name, "boolean", "true")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def disable(name, actor = nil)
|
|
70
|
+
feature_operation(name, :disable) do |payload|
|
|
71
|
+
if actor
|
|
72
|
+
adapter.remove_gate(name, "actors", Actor.id(actor))
|
|
73
|
+
payload[:actor_id] = Actor.id(actor)
|
|
74
|
+
else
|
|
75
|
+
adapter.clear_gates(name)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def enable_group(name, group_name)
|
|
81
|
+
feature_operation(name, :enable_group, group_name: group_name.to_s) do
|
|
82
|
+
add(name)
|
|
83
|
+
adapter.add_gate(name, "groups", group_name.to_s)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def disable_group(name, group_name)
|
|
88
|
+
feature_operation(name, :disable_group, group_name: group_name.to_s) do
|
|
89
|
+
adapter.remove_gate(name, "groups", group_name.to_s)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def enable_percentage_of_actors(name, percentage)
|
|
94
|
+
clamped = percentage.to_i.clamp(0, 100)
|
|
95
|
+
feature_operation(name, :enable_percentage_of_actors, percentage: clamped) do
|
|
96
|
+
add(name)
|
|
97
|
+
adapter.remove_gate(name, "percentage_of_actors")
|
|
98
|
+
adapter.add_gate(name, "percentage_of_actors", clamped.to_s)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def set_variants(name, variants)
|
|
103
|
+
feature_operation(name, :set_variants) do
|
|
104
|
+
add(name)
|
|
105
|
+
adapter.remove_gate(name, "variants")
|
|
106
|
+
adapter.add_gate(name, "variants", JSON.generate(variants.transform_keys(&:to_s)))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def add(name)
|
|
111
|
+
Registry.require_definition!(name)
|
|
112
|
+
adapter.add(name)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def exist?(name)
|
|
116
|
+
adapter.exist?(name)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def features
|
|
120
|
+
adapter.feature_keys.map { |key| Flag.new(key) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def [](name)
|
|
124
|
+
Flag.new(name)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def register_group(name, &block)
|
|
128
|
+
Group.register(name, &block)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Import from any prior feature-flag store via a source with #feature_keys and #gates.
|
|
132
|
+
def migrate!(source)
|
|
133
|
+
Migrator::Importer.new(source).call
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def sync_registry!
|
|
137
|
+
Registry.config.each_key { |key| add(key) unless exist?(key) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def purge_stats!(**kwargs)
|
|
141
|
+
Stats.purge!(**kwargs)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def stats_overview
|
|
145
|
+
Stats.overview
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Preload gates into the fiber-local request store (one adapter round-trip).
|
|
149
|
+
# Pass names, or omit to load every YAML registry key.
|
|
150
|
+
def preload!(*names)
|
|
151
|
+
keys = names.flatten.map!(&:to_s)
|
|
152
|
+
keys = Registry.config.keys.map(&:to_s) if keys.empty?
|
|
153
|
+
loaded =
|
|
154
|
+
if adapter.respond_to?(:gates_for_many)
|
|
155
|
+
adapter.gates_for_many(keys)
|
|
156
|
+
else
|
|
157
|
+
keys.each_with_object({}) { |key, hash| hash[key] = adapter.gates_for(key) }
|
|
158
|
+
end
|
|
159
|
+
RequestStore.gates = (RequestStore.gates || {}).merge(loaded)
|
|
160
|
+
loaded.size
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def flush_stats!
|
|
164
|
+
Stats::Buffer.flush!
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def reset_request!
|
|
168
|
+
Stats::Buffer.flush! if configuration.stats_buffer
|
|
169
|
+
RequestStore.clear!
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def feature_operation(name, operation, **fields)
|
|
175
|
+
Instrumenter.instrument("feature_operation", feature_name: name.to_s, operation: operation, **fields) do |payload|
|
|
176
|
+
Registry.require_definition!(name)
|
|
177
|
+
yield(payload)
|
|
178
|
+
Store.bust_cache!(name)
|
|
179
|
+
payload[:result] = true
|
|
180
|
+
true
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def build_adapter
|
|
185
|
+
case configuration.adapter
|
|
186
|
+
when :memory then Adapters::Memory.new
|
|
187
|
+
when :active_record then Adapters::ActiveRecord.new
|
|
188
|
+
else
|
|
189
|
+
raise ArgumentError, "Unknown Onni adapter #{configuration.adapter.inspect}"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
data/onni.gemspec
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/onni/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "onni"
|
|
7
|
+
spec.version = Onni::VERSION
|
|
8
|
+
spec.authors = ["Andrei Makarov"]
|
|
9
|
+
spec.email = ["contact@kiskolabs.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Feature flags, A/B variants, and hit stats for Ruby apps"
|
|
12
|
+
spec.description = <<~TEXT
|
|
13
|
+
Feature-flag and experiment core for any Ruby app: YAML registry, runtime hot
|
|
14
|
+
switches, actor and audience-group targeting, percentage / A/B assignment, and
|
|
15
|
+
hit stats for analysis. Not an authorization system. Optional ActiveRecord
|
|
16
|
+
adapter and pluggable ActiveAdmin dashboard. Includes a pluggable migrator
|
|
17
|
+
for importing gates from another feature-flag library.
|
|
18
|
+
TEXT
|
|
19
|
+
spec.license = "MIT"
|
|
20
|
+
spec.platform = Gem::Platform::RUBY
|
|
21
|
+
spec.required_ruby_version = ">= 3.4.0"
|
|
22
|
+
|
|
23
|
+
repository_url = "https://github.com/amkisko/onni.rb"
|
|
24
|
+
|
|
25
|
+
spec.homepage = repository_url
|
|
26
|
+
spec.metadata = {
|
|
27
|
+
"homepage_uri" => repository_url,
|
|
28
|
+
"source_code_uri" => "#{repository_url}/tree/main",
|
|
29
|
+
"changelog_uri" => "#{repository_url}/blob/main/CHANGELOG.md",
|
|
30
|
+
"bug_tracker_uri" => "#{repository_url}/issues",
|
|
31
|
+
"documentation_uri" => "#{repository_url}#readme",
|
|
32
|
+
"allowed_push_host" => "https://rubygems.org",
|
|
33
|
+
"rubygems_mfa_required" => "true"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
spec.files = Dir.chdir(__dir__) do
|
|
37
|
+
%w[
|
|
38
|
+
CHANGELOG.md
|
|
39
|
+
CODE_OF_CONDUCT.md
|
|
40
|
+
CONTRIBUTING.md
|
|
41
|
+
GOVERNANCE.md
|
|
42
|
+
LICENSE.md
|
|
43
|
+
README.md
|
|
44
|
+
SECURITY.md
|
|
45
|
+
onni.gemspec
|
|
46
|
+
].select { |path| File.file?(path) } +
|
|
47
|
+
Dir["{app,config,lib,sig}/**/*"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
spec.require_paths = ["lib"]
|
|
51
|
+
|
|
52
|
+
spec.add_dependency "activesupport", ">= 7.2.3.2"
|
|
53
|
+
|
|
54
|
+
spec.add_development_dependency "activeadmin", ">= 4.0.0.beta23", "< 5"
|
|
55
|
+
spec.add_development_dependency "appraisal", "~> 2"
|
|
56
|
+
spec.add_development_dependency "bundler", ">= 2"
|
|
57
|
+
spec.add_development_dependency "bundler-audit", "~> 0.9"
|
|
58
|
+
spec.add_development_dependency "importmap-rails"
|
|
59
|
+
spec.add_development_dependency "polyrun", ">= 3.0.0"
|
|
60
|
+
spec.add_development_dependency "rails", ">= 7.2.3.2"
|
|
61
|
+
spec.add_development_dependency "rake", "~> 13"
|
|
62
|
+
spec.add_development_dependency "rbs", "~> 4"
|
|
63
|
+
spec.add_development_dependency "rspec", "~> 3"
|
|
64
|
+
spec.add_development_dependency "rspec-rails", ">= 6"
|
|
65
|
+
spec.add_development_dependency "sqlite3", ">= 2.10.2"
|
|
66
|
+
spec.add_development_dependency "rubocop-rspec", "~> 3.8"
|
|
67
|
+
spec.add_development_dependency "rubocop-thread_safety", "~> 0.7"
|
|
68
|
+
spec.add_development_dependency "standard", "~> 1.52"
|
|
69
|
+
spec.add_development_dependency "standard-custom", "~> 1.0"
|
|
70
|
+
spec.add_development_dependency "standard-performance", "~> 1.8"
|
|
71
|
+
spec.add_development_dependency "standard-rspec", "~> 0.3"
|
|
72
|
+
end
|