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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/CODE_OF_CONDUCT.md +26 -0
  4. data/CONTRIBUTING.md +23 -0
  5. data/GOVERNANCE.md +25 -0
  6. data/LICENSE.md +21 -0
  7. data/README.md +301 -0
  8. data/SECURITY.md +25 -0
  9. data/app/admin/features.rb +97 -0
  10. data/app/models/onni/feature.rb +14 -0
  11. data/app/models/onni/gate.rb +12 -0
  12. data/app/models/onni/hit_chart.rb +82 -0
  13. data/app/models/onni/stat_bucket.rb +12 -0
  14. data/app/views/admin/features/_chart.html.erb +24 -0
  15. data/app/views/admin/features/_stats.html.erb +49 -0
  16. data/app/views/admin/features/show.html.erb +60 -0
  17. data/config/locales/en.yml +6 -0
  18. data/config/polyrun_coverage.yml +8 -0
  19. data/lib/generators/onni/install_generator.rb +37 -0
  20. data/lib/generators/onni/stats_cleanup_generator.rb +50 -0
  21. data/lib/generators/onni/templates/add_onni_stats_period_start_index.rb.tt +9 -0
  22. data/lib/generators/onni/templates/create_onni_features.rb.tt +34 -0
  23. data/lib/generators/onni/templates/features.yml.tt +9 -0
  24. data/lib/generators/onni/templates/initializer.rb.tt +41 -0
  25. data/lib/generators/onni/templates/purge_stats_job.rb.tt +26 -0
  26. data/lib/onni/active_admin.rb +13 -0
  27. data/lib/onni/actor.rb +31 -0
  28. data/lib/onni/adapters/active_record.rb +128 -0
  29. data/lib/onni/adapters/memory.rb +161 -0
  30. data/lib/onni/conditions.rb +30 -0
  31. data/lib/onni/configuration.rb +54 -0
  32. data/lib/onni/engine.rb +50 -0
  33. data/lib/onni/flag.rb +53 -0
  34. data/lib/onni/group.rb +36 -0
  35. data/lib/onni/instrumenter.rb +27 -0
  36. data/lib/onni/migrator/importer.rb +55 -0
  37. data/lib/onni/migrator/snapshot.rb +15 -0
  38. data/lib/onni/registry.rb +78 -0
  39. data/lib/onni/request_store.rb +33 -0
  40. data/lib/onni/stats/buffer.rb +35 -0
  41. data/lib/onni/stats/overview.rb +34 -0
  42. data/lib/onni/stats/purge.rb +77 -0
  43. data/lib/onni/stats/sampler.rb +62 -0
  44. data/lib/onni/stats.rb +45 -0
  45. data/lib/onni/store.rb +155 -0
  46. data/lib/onni/sync.rb +43 -0
  47. data/lib/onni/version.rb +5 -0
  48. data/lib/onni.rb +193 -0
  49. data/onni.gemspec +72 -0
  50. data/sig/onni.rbs +210 -0
  51. 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
@@ -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