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,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onni
|
|
4
|
+
module Adapters
|
|
5
|
+
class Memory
|
|
6
|
+
EMPTY_GATES = [].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(lock: Sync.new_lock)
|
|
9
|
+
@features = {}
|
|
10
|
+
@gates = {}
|
|
11
|
+
@stats = {}
|
|
12
|
+
@lock = lock
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def clear!
|
|
16
|
+
@lock.synchronize do
|
|
17
|
+
@features.clear
|
|
18
|
+
@gates.clear
|
|
19
|
+
@stats.clear
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def add(key)
|
|
24
|
+
@lock.synchronize { @features[key.to_s] = true }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def exist?(key)
|
|
28
|
+
@lock.synchronize do
|
|
29
|
+
@features.key?(key.to_s) || @gates.fetch(key.to_s, []).any?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def feature_keys
|
|
34
|
+
@lock.synchronize do
|
|
35
|
+
(@features.keys + @gates.select { |_key, gates| gates.any? }.keys).uniq.sort
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def gates_for(key)
|
|
40
|
+
@lock.synchronize { gates_for_unlocked(key.to_s) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def gates_for_many(keys)
|
|
44
|
+
@lock.synchronize do
|
|
45
|
+
keys.each_with_object({}) do |key, loaded|
|
|
46
|
+
loaded[key.to_s] = gates_for_unlocked(key.to_s)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def replace_gates(key, gates)
|
|
52
|
+
@lock.synchronize { @gates[key.to_s] = gates.map { |gate| stringify(gate) } }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def add_gate(key, gate_key, value)
|
|
56
|
+
@lock.synchronize do
|
|
57
|
+
list = (@gates[key.to_s] ||= [])
|
|
58
|
+
entry = {"key" => gate_key.to_s, "value" => value.to_s}
|
|
59
|
+
list << entry unless list.any? { |gate| gate["key"] == entry["key"] && gate["value"] == entry["value"] }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def remove_gate(key, gate_key, value = nil)
|
|
64
|
+
@lock.synchronize do
|
|
65
|
+
list = @gates[key.to_s]
|
|
66
|
+
next if list.nil?
|
|
67
|
+
|
|
68
|
+
list.reject! do |gate|
|
|
69
|
+
gate["key"] == gate_key.to_s && (value.nil? || gate["value"] == value.to_s)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def clear_gates(key)
|
|
75
|
+
@lock.synchronize { @gates[key.to_s] = [] }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def record_stat(key, enabled:, weight: 1)
|
|
79
|
+
weight = weight.to_i.clamp(1, Stats::Sampler::MAX_WEIGHT)
|
|
80
|
+
@lock.synchronize do
|
|
81
|
+
bucket = (@stats[stat_bucket_key(key)] ||= {enabled: 0, disabled: 0})
|
|
82
|
+
bucket[enabled ? :enabled : :disabled] += weight
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def record_stats_batch(buckets)
|
|
87
|
+
@lock.synchronize do
|
|
88
|
+
buckets.each do |feature_key, counts|
|
|
89
|
+
bucket = (@stats[stat_bucket_key(feature_key)] ||= {enabled: 0, disabled: 0})
|
|
90
|
+
bucket[:enabled] += counts[:enabled].to_i
|
|
91
|
+
bucket[:disabled] += counts[:disabled].to_i
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
buckets.size
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def stats_for(key, period: "hour", from: 24.hours.ago, to: Time.current)
|
|
98
|
+
@lock.synchronize do
|
|
99
|
+
@stats.filter_map do |bucket_key, counts|
|
|
100
|
+
feature_key, period_start = bucket_key.split("|", 2)
|
|
101
|
+
next unless feature_key == key.to_s
|
|
102
|
+
|
|
103
|
+
started = Time.parse("#{period_start} UTC")
|
|
104
|
+
next unless started.between?(from, to)
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
"period_start" => started,
|
|
108
|
+
"period" => period,
|
|
109
|
+
"enabled_hits" => counts[:enabled],
|
|
110
|
+
"disabled_hits" => counts[:disabled]
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def purge_stats_batch(older_than:, batch_size:, after_id: 0)
|
|
117
|
+
@lock.synchronize do
|
|
118
|
+
matching = @stats.keys.select { |bucket_key|
|
|
119
|
+
_feature_key, period_start = bucket_key.split("|", 2)
|
|
120
|
+
Time.parse("#{period_start} UTC") < older_than
|
|
121
|
+
}.sort
|
|
122
|
+
slice = matching.first(batch_size)
|
|
123
|
+
slice.each { |bucket_key| @stats.delete(bucket_key) }
|
|
124
|
+
return {deleted: 0, last_id: nil} if slice.empty?
|
|
125
|
+
|
|
126
|
+
{deleted: slice.size, last_id: (slice.size == batch_size) ? (after_id + slice.size) : nil}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def stats_overview
|
|
131
|
+
@lock.synchronize do
|
|
132
|
+
starts = @stats.keys.filter_map { |bucket_key|
|
|
133
|
+
_feature_key, period_start = bucket_key.split("|", 2)
|
|
134
|
+
Time.parse("#{period_start} UTC")
|
|
135
|
+
}
|
|
136
|
+
{
|
|
137
|
+
bucket_count: @stats.size,
|
|
138
|
+
oldest_period_start: starts.min
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def gates_for_unlocked(key)
|
|
146
|
+
list = @gates[key]
|
|
147
|
+
return EMPTY_GATES if list.nil? || list.empty?
|
|
148
|
+
|
|
149
|
+
list.map(&:dup)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def stringify(gate)
|
|
153
|
+
{"key" => gate[:key] || gate["key"], "value" => (gate[:value] || gate["value"]).to_s}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def stat_bucket_key(key)
|
|
157
|
+
"#{key}|#{Time.now.utc.strftime("%Y-%m-%dT%H:00:00")}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onni
|
|
4
|
+
# YAML `conditions` narrow a rollout after a gate matches (audience targeting).
|
|
5
|
+
# They are not authorization. Supported entries:
|
|
6
|
+
# - group name registered via Onni.register_group
|
|
7
|
+
# - actor predicate method name ending in ? (for example beta_tester?)
|
|
8
|
+
module Conditions
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def met?(name, actor, definition: nil)
|
|
12
|
+
definition ||= Registry.definition(name) || {}
|
|
13
|
+
conditions = Array(definition["conditions"])
|
|
14
|
+
return true if conditions.empty?
|
|
15
|
+
# Global checks (no actor) only answer whether a gate is armed.
|
|
16
|
+
return true if actor.nil?
|
|
17
|
+
|
|
18
|
+
conditions.all? { |condition| match?(condition, actor) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def match?(condition, actor)
|
|
22
|
+
name = condition.to_s
|
|
23
|
+
return Group.member?(name, actor) if Group.known?(name)
|
|
24
|
+
return actor.public_send(name) if name.end_with?("?") && actor.respond_to?(name)
|
|
25
|
+
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
private_class_method :match?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext/numeric/time"
|
|
5
|
+
require "onni/stats/sampler"
|
|
6
|
+
|
|
7
|
+
module Onni
|
|
8
|
+
class Configuration
|
|
9
|
+
CONCURRENCY_MODES = %i[mutex none].freeze
|
|
10
|
+
|
|
11
|
+
attr_accessor :features_path, :cache, :cache_ttl, :stats_enabled, :stats_retention,
|
|
12
|
+
:stats_sampler, :stats_ignore, :stats_scale_sampled_hits, :stats_buffer, :adapter, :admin_authorized
|
|
13
|
+
attr_reader :stats_sample_rate, :concurrency
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@features_path = nil
|
|
17
|
+
@cache = nil
|
|
18
|
+
@cache_ttl = 15 * 60
|
|
19
|
+
@stats_enabled = true
|
|
20
|
+
@stats_sample_rate = 1.0
|
|
21
|
+
# Optional callable `{name:, enabled:} -> rate|bool|nil` (nil falls back to stats_sample_rate).
|
|
22
|
+
@stats_sampler = nil
|
|
23
|
+
# Feature keys that never write hit buckets.
|
|
24
|
+
@stats_ignore = []
|
|
25
|
+
# When true, a kept sample increments by ~1/rate so totals estimate full traffic.
|
|
26
|
+
@stats_scale_sampled_hits = true
|
|
27
|
+
# When true, hit stats coalesce in the fiber-local buffer until Onni.flush_stats! / reset_request!.
|
|
28
|
+
@stats_buffer = false
|
|
29
|
+
# How long hourly hit buckets are kept. Used by Onni::Stats::Purge / the cleanup job.
|
|
30
|
+
@stats_retention = 90.days
|
|
31
|
+
# :mutex covers threads and fibers on Ruby 3.4+; :none skips locking.
|
|
32
|
+
@concurrency = :mutex
|
|
33
|
+
@adapter = :memory
|
|
34
|
+
# Who may open the Onni ActiveAdmin dashboard (not application authorization).
|
|
35
|
+
# Called with the ActiveAdmin page controller as receiver (instance_exec).
|
|
36
|
+
@admin_authorized = -> { true }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def stats_sample_rate=(value)
|
|
40
|
+
@stats_sample_rate = Stats::Sampler.coerce_rate(value)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def concurrency=(value)
|
|
44
|
+
mode = value.to_sym
|
|
45
|
+
unless CONCURRENCY_MODES.include?(mode)
|
|
46
|
+
raise ArgumentError, "Unknown Onni concurrency #{value.inspect} (expected #{CONCURRENCY_MODES.join(", ")})"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@concurrency = mode
|
|
50
|
+
Sync.reset! if defined?(Onni::Sync)
|
|
51
|
+
Onni.reset_adapter! if Onni.respond_to?(:reset_adapter!)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/onni/engine.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(Rails::Engine)
|
|
4
|
+
|
|
5
|
+
module Onni
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
engine_name "onni"
|
|
8
|
+
|
|
9
|
+
initializer "onni.configure", before: :load_config_initializers do
|
|
10
|
+
Onni.configure do |config|
|
|
11
|
+
config.features_path = Rails.root.join("config/features.yml")
|
|
12
|
+
config.cache = Rails.cache
|
|
13
|
+
config.adapter = Rails.env.test? ? :memory : :active_record
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Rails engines eager-load every app/* directory. ActiveAdmin DSL files are
|
|
18
|
+
# not Zeitwerk constants; ActiveAdmin loads them via load_paths.
|
|
19
|
+
initializer "onni.configure_zeitwerk" do
|
|
20
|
+
Rails.autoloaders.main.ignore(root.join("app/admin"))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
initializer "onni.load_active_admin" do
|
|
24
|
+
next unless defined?(ActiveAdmin)
|
|
25
|
+
|
|
26
|
+
path = root.join("app/admin").to_s
|
|
27
|
+
ActiveSupport.on_load(:after_initialize) do
|
|
28
|
+
ActiveAdmin.application.load_paths << path unless ActiveAdmin.application.load_paths.include?(path)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
initializer "onni.i18n" do
|
|
33
|
+
I18n.load_path += Dir[root.join("config/locales/*.yml")]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
config.after_initialize do
|
|
37
|
+
boot_sync_registry!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.boot_sync_registry!
|
|
41
|
+
return unless Rails.env.local?
|
|
42
|
+
return if defined?(Rake) && Rake.application.top_level_tasks.any? { |task| task.start_with?("db:") }
|
|
43
|
+
return if Onni.configuration.adapter == :memory
|
|
44
|
+
|
|
45
|
+
Onni.sync_registry!
|
|
46
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/onni/flag.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onni
|
|
4
|
+
class Flag
|
|
5
|
+
attr_reader :name
|
|
6
|
+
|
|
7
|
+
def initialize(name)
|
|
8
|
+
@name = name.to_s
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def exist?
|
|
12
|
+
Registry.known?(name)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def enabled?(actor = nil, record_stats: true)
|
|
16
|
+
Onni.enabled?(name, actor, record_stats: record_stats)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def gates
|
|
20
|
+
@gates ||= Onni.adapter.gates_for(name)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def definition
|
|
24
|
+
@definition ||= Registry.definition(name) || {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def description
|
|
28
|
+
definition["description"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def expires_at
|
|
32
|
+
definition["expires_at"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def variants
|
|
36
|
+
raw = definition["variants"] || gates_variants
|
|
37
|
+
return {} if raw.blank?
|
|
38
|
+
|
|
39
|
+
raw.transform_keys(&:to_s).transform_values(&:to_i)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def gates_variants
|
|
45
|
+
gate = gates.find { |entry| entry["key"] == "variants" }
|
|
46
|
+
return {} unless gate
|
|
47
|
+
|
|
48
|
+
JSON.parse(gate["value"])
|
|
49
|
+
rescue JSON::ParserError
|
|
50
|
+
{}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/onni/group.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onni
|
|
4
|
+
module Group
|
|
5
|
+
@registry = {}.freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def register(name, &block)
|
|
10
|
+
Sync.synchronize do
|
|
11
|
+
next_registry = @registry.dup
|
|
12
|
+
next_registry[name.to_sym] = block
|
|
13
|
+
@registry = next_registry
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def clear!
|
|
18
|
+
Sync.synchronize { @registry = {} }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def names
|
|
22
|
+
Sync.synchronize { @registry.keys }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def known?(name)
|
|
26
|
+
Sync.synchronize { @registry.key?(name.to_sym) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def member?(name, actor)
|
|
30
|
+
block = Sync.synchronize { @registry[name.to_sym] }
|
|
31
|
+
return false unless block
|
|
32
|
+
|
|
33
|
+
block.call(actor, name.to_sym)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onni
|
|
4
|
+
# Publishes ActiveSupport::Notifications with a `.onni` suffix
|
|
5
|
+
# (for example `stats.record.onni`).
|
|
6
|
+
module Instrumenter
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def instrument(name, payload = {})
|
|
10
|
+
bus = notifications_bus
|
|
11
|
+
if bus
|
|
12
|
+
bus.instrument("#{name}.onni", payload) do |event_payload|
|
|
13
|
+
yield(event_payload) if block_given?
|
|
14
|
+
end
|
|
15
|
+
elsif block_given?
|
|
16
|
+
yield(payload)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def notifications_bus
|
|
21
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
22
|
+
|
|
23
|
+
ActiveSupport::Notifications
|
|
24
|
+
end
|
|
25
|
+
private_class_method :notifications_bus
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onni
|
|
4
|
+
module Migrator
|
|
5
|
+
# Copies gates from any source that exposes #feature_keys and #gates.
|
|
6
|
+
# Gate rows: feature_key, key, value (String or Symbol keys).
|
|
7
|
+
# Imports: boolean, actors, groups, percentage_of_actors, variants.
|
|
8
|
+
# Skips unknown registry keys and unsupported gate keys (counted in skipped_gates).
|
|
9
|
+
class Importer
|
|
10
|
+
SUPPORTED_GATE_KEYS = %w[boolean actors groups percentage_of_actors variants].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(source)
|
|
13
|
+
@source = source
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
features = 0
|
|
18
|
+
gates = 0
|
|
19
|
+
skipped_gates = 0
|
|
20
|
+
|
|
21
|
+
@source.feature_keys.each do |key|
|
|
22
|
+
next unless Registry.known?(key)
|
|
23
|
+
|
|
24
|
+
Onni.add(key)
|
|
25
|
+
features += 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@source.gates.each do |row|
|
|
29
|
+
feature_key = gate_field(row, :feature_key)
|
|
30
|
+
gate_key = gate_field(row, :key).to_s
|
|
31
|
+
value = gate_field(row, :value)
|
|
32
|
+
next unless Registry.known?(feature_key)
|
|
33
|
+
|
|
34
|
+
unless SUPPORTED_GATE_KEYS.include?(gate_key)
|
|
35
|
+
skipped_gates += 1
|
|
36
|
+
next
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Onni.add(feature_key)
|
|
40
|
+
Onni.adapter.add_gate(feature_key, gate_key, value)
|
|
41
|
+
Onni::Store.bust_cache!(feature_key)
|
|
42
|
+
gates += 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
{features: features, gates: gates, skipped_gates: skipped_gates}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def gate_field(row, name)
|
|
51
|
+
row[name] || row[name.to_s]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onni
|
|
4
|
+
module Migrator
|
|
5
|
+
# In-memory source for Onni.migrate!. Wrap rows from any prior feature-flag store.
|
|
6
|
+
class Snapshot
|
|
7
|
+
attr_reader :feature_keys, :gates
|
|
8
|
+
|
|
9
|
+
def initialize(feature_keys:, gates:)
|
|
10
|
+
@feature_keys = Array(feature_keys)
|
|
11
|
+
@gates = Array(gates)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Onni
|
|
7
|
+
module Registry
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def config
|
|
11
|
+
loaded = @config
|
|
12
|
+
return loaded if loaded
|
|
13
|
+
|
|
14
|
+
Sync.synchronize do
|
|
15
|
+
@config ||= load_config
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reload!
|
|
20
|
+
Sync.synchronize do
|
|
21
|
+
@config = nil
|
|
22
|
+
end
|
|
23
|
+
config
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def known?(name)
|
|
27
|
+
config.key?(name.to_s)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def definition(name)
|
|
31
|
+
config[name.to_s]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def require_definition!(name)
|
|
35
|
+
definition(name) || raise_unknown!(name)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def raise_unknown!(name)
|
|
39
|
+
message = "Feature `#{name}` not found in features.yml"
|
|
40
|
+
if defined?(Rails)
|
|
41
|
+
Rails.logger.error(message)
|
|
42
|
+
ActionReporter.notify(message) if defined?(ActionReporter) && Rails.env.production?
|
|
43
|
+
end
|
|
44
|
+
raise KeyError, message
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def load_config
|
|
48
|
+
path = Onni.configuration.features_path
|
|
49
|
+
path ||= default_features_path
|
|
50
|
+
return {} if path.nil? || !File.exist?(path)
|
|
51
|
+
|
|
52
|
+
raw = File.read(path)
|
|
53
|
+
loaded = YAML.safe_load(raw, aliases: true)
|
|
54
|
+
return {} unless loaded.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
loaded.each_value { |definition| attach_expiry!(definition) if definition.is_a?(Hash) }
|
|
57
|
+
loaded
|
|
58
|
+
end
|
|
59
|
+
private_class_method :load_config
|
|
60
|
+
|
|
61
|
+
def attach_expiry!(definition)
|
|
62
|
+
raw = definition["expires_at"]
|
|
63
|
+
return if raw.nil? || raw.to_s.empty?
|
|
64
|
+
|
|
65
|
+
definition["expires_at_time"] = Time.parse(raw.to_s).utc
|
|
66
|
+
rescue ArgumentError, TypeError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
private_class_method :attach_expiry!
|
|
70
|
+
|
|
71
|
+
def default_features_path
|
|
72
|
+
return unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
73
|
+
|
|
74
|
+
Rails.root.join("config/features.yml")
|
|
75
|
+
end
|
|
76
|
+
private_class_method :default_features_path
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onni
|
|
4
|
+
# Fiber-local request scratch (gates preload, stats buffer).
|
|
5
|
+
# Hosts clear via Onni.reset_request! at request end.
|
|
6
|
+
module RequestStore
|
|
7
|
+
GATES_KEY = :onni_request_gates
|
|
8
|
+
STATS_KEY = :onni_stats_buffer
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def gates
|
|
13
|
+
Fiber[GATES_KEY]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def gates=(value)
|
|
17
|
+
Fiber[GATES_KEY] = value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def stats_buffer
|
|
21
|
+
Fiber[STATS_KEY]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def stats_buffer=(value)
|
|
25
|
+
Fiber[STATS_KEY] = value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def clear!
|
|
29
|
+
Fiber[GATES_KEY] = nil
|
|
30
|
+
Fiber[STATS_KEY] = nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onni
|
|
4
|
+
module Stats
|
|
5
|
+
# Coalesces hit weights per feature until flush (request end / host job).
|
|
6
|
+
module Buffer
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def add(name, enabled:, weight:)
|
|
10
|
+
buckets = RequestStore.stats_buffer || {}
|
|
11
|
+
key = name.to_s
|
|
12
|
+
bucket = (buckets[key] ||= {enabled: 0, disabled: 0})
|
|
13
|
+
bucket[enabled ? :enabled : :disabled] += weight.to_i
|
|
14
|
+
RequestStore.stats_buffer = buckets
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def flush!
|
|
18
|
+
buckets = RequestStore.stats_buffer
|
|
19
|
+
RequestStore.stats_buffer = nil
|
|
20
|
+
return 0 if buckets.nil? || buckets.empty?
|
|
21
|
+
|
|
22
|
+
adapter = Onni.adapter
|
|
23
|
+
if adapter.respond_to?(:record_stats_batch)
|
|
24
|
+
adapter.record_stats_batch(buckets)
|
|
25
|
+
else
|
|
26
|
+
buckets.each do |feature_key, counts|
|
|
27
|
+
adapter.record_stat(feature_key, enabled: true, weight: counts[:enabled]) if counts[:enabled].positive?
|
|
28
|
+
adapter.record_stat(feature_key, enabled: false, weight: counts[:disabled]) if counts[:disabled].positive?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
buckets.size
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onni
|
|
4
|
+
module Stats
|
|
5
|
+
# Operator-facing storage summary so retention and growth stay visible.
|
|
6
|
+
module Overview
|
|
7
|
+
ROW_WARNING = 1_000_000
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
raw = Onni.adapter.stats_overview
|
|
13
|
+
retention = Onni.configuration.stats_retention
|
|
14
|
+
oldest = raw[:oldest_period_start]
|
|
15
|
+
past_retention =
|
|
16
|
+
if oldest && retention
|
|
17
|
+
oldest < retention.ago
|
|
18
|
+
else
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
bucket_count: raw[:bucket_count].to_i,
|
|
24
|
+
oldest_period_start: oldest,
|
|
25
|
+
retention: retention,
|
|
26
|
+
sample_rate: Onni.configuration.stats_sample_rate,
|
|
27
|
+
scale_sampled_hits: Onni.configuration.stats_scale_sampled_hits,
|
|
28
|
+
past_retention: past_retention,
|
|
29
|
+
large_table: raw[:bucket_count].to_i >= ROW_WARNING
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|