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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ class StatBucket < ::ActiveRecord::Base
5
+ self.table_name = "onni_feature_stat_buckets"
6
+
7
+ belongs_to :feature, class_name: "Onni::Feature", foreign_key: :feature_key,
8
+ primary_key: :key, inverse_of: :stat_buckets, optional: true
9
+
10
+ validates :feature_key, :period, :period_start, presence: true
11
+ end
12
+ end
@@ -0,0 +1,24 @@
1
+ <figure class="my-4">
2
+ <figcaption>
3
+ <svg width="28" height="12" aria-hidden="true"><line x1="0" y1="6" x2="28" y2="6" stroke="#2563eb" stroke-width="3" /></svg>
4
+ Enabled
5
+ <svg width="28" height="12" aria-hidden="true"><line x1="0" y1="6" x2="28" y2="6" stroke="#c2410c" stroke-width="3" stroke-dasharray="6 4" /></svg>
6
+ Disabled
7
+ <p>Scale: 0 to <%= number_with_delimiter(chart.maximum) %> evaluations per <%= chart.hourly? ? "hour" : "day" %>.</p>
8
+ </figcaption>
9
+ <svg viewBox="40 8 752 216" width="100%" height="220" preserveAspectRatio="none" role="img" aria-labelledby="onni-hit-chart-title onni-hit-chart-description">
10
+ <title id="onni-hit-chart-title">Enabled and disabled evaluations over time</title>
11
+ <desc id="onni-hit-chart-description">A solid blue line shows enabled evaluations and a dashed orange line shows disabled evaluations. Exact counts are available in the table below.</desc>
12
+ <% [24, 116, 208].each do |y| %>
13
+ <line x1="56" y1="<%= y %>" x2="776" y2="<%= y %>" stroke="currentColor" stroke-opacity="0.2" vector-effect="non-scaling-stroke" />
14
+ <% end %>
15
+ <polyline points="<%= chart.points("enabled_hits") %>" fill="none" stroke="#2563eb" stroke-width="3" vector-effect="non-scaling-stroke" />
16
+ <polyline points="<%= chart.points("disabled_hits") %>" fill="none" stroke="#c2410c" stroke-width="3" stroke-dasharray="6 4" vector-effect="non-scaling-stroke" />
17
+ </svg>
18
+ <p>
19
+ <%= l(chart.hourly? ? chart.rows.first.fetch("period_start") : chart.rows.first.fetch("period_start").to_date, format: :short) %>
20
+ to
21
+ <%= l(chart.hourly? ? chart.rows.last.fetch("period_start") : chart.rows.last.fetch("period_start").to_date, format: :short) %>
22
+ (UTC)
23
+ </p>
24
+ </figure>
@@ -0,0 +1,49 @@
1
+ <section aria-labelledby="onni-hit-history" class="mb-6">
2
+ <h3 id="onni-hit-history" class="font-semibold mb-2">Hits</h3>
3
+ <%= form_with url: admin_features_feature_path, method: :get, local: true, class: "mb-4" do %>
4
+ <%= hidden_field_tag :key, flag.name %>
5
+ <%= label_tag :range, "History" %>
6
+ <%= select_tag :range, options_for_select([["Last 24 hours", "24h"], ["Last 7 days", "7d"], ["Last 30 days", "30d"]], chart.range) %>
7
+ <%= submit_tag "Show history", name: nil, class: "button" %>
8
+ <% end %>
9
+
10
+ <dl class="mb-4">
11
+ <dt>Enabled evaluations</dt>
12
+ <dd data-onni-enabled-hits><%= number_with_delimiter(chart.enabled_hits) %></dd>
13
+ <dt>Disabled evaluations</dt>
14
+ <dd data-onni-disabled-hits><%= number_with_delimiter(chart.disabled_hits) %></dd>
15
+ <dt>Enabled share</dt>
16
+ <dd data-onni-enabled-percentage><%= chart.enabled_percentage ? number_to_percentage(chart.enabled_percentage, precision: 1) : "No data" %></dd>
17
+ </dl>
18
+ <p>Counts reflect feature evaluations, not unique users. Sampling can affect these counts: they may include estimates or only sampled evaluations.</p>
19
+
20
+ <% if chart.enabled_percentage.nil? %>
21
+ <p role="status" class="my-4">No recorded evaluations in this window. Choose a longer window or check whether statistics collection is enabled.</p>
22
+ <% else %>
23
+ <%= render partial: "admin/features/chart", locals: { chart: chart } %>
24
+ <% end %>
25
+
26
+ <p>Times are UTC. The current <%= chart.hourly? ? "hour" : "day" %> is incomplete. Periods without recorded evaluations are shown as zero; older history may have been removed.</p>
27
+ <details class="mt-4">
28
+ <summary>View evaluation counts</summary>
29
+ <table>
30
+ <caption>Recorded evaluations by <%= chart.hourly? ? "hour" : "day" %> (UTC)</caption>
31
+ <thead>
32
+ <tr>
33
+ <th scope="col"><%= chart.hourly? ? "Hour" : "Day" %> (UTC)</th>
34
+ <th scope="col">Enabled</th>
35
+ <th scope="col">Disabled</th>
36
+ </tr>
37
+ </thead>
38
+ <tbody>
39
+ <% chart.rows.each do |row| %>
40
+ <tr>
41
+ <th scope="row"><%= l(chart.hourly? ? row.fetch("period_start") : row.fetch("period_start").to_date, format: :short) %></th>
42
+ <td><%= number_with_delimiter(row.fetch("enabled_hits")) %></td>
43
+ <td><%= number_with_delimiter(row.fetch("disabled_hits")) %></td>
44
+ </tr>
45
+ <% end %>
46
+ </tbody>
47
+ </table>
48
+ </details>
49
+ </section>
@@ -0,0 +1,60 @@
1
+ <% flag = @flag %>
2
+ <% variants = flag.variants %>
3
+ <div class="p-4">
4
+ <h2 class="text-xl font-semibold mb-2"><%= flag.name %></h2>
5
+ <p class="mb-4"><%= flag.description %></p>
6
+ <p class="mb-4">Expires: <%= flag.expires_at || "—" %></p>
7
+ <p class="mb-4">
8
+ Hit sample rate:
9
+ <% if !Onni.configuration.stats_enabled %>
10
+ off
11
+ <% elsif Array(Onni.configuration.stats_ignore).any? { |key| key.to_s == flag.name } %>
12
+ ignored
13
+ <% else %>
14
+ <%= Onni.configuration.stats_sample_rate %>
15
+ <% if Onni.configuration.stats_sampler %>
16
+ (global default; per-flag sampler may override)
17
+ <% end %>
18
+ <% end %>
19
+ </p>
20
+
21
+ <h3 class="font-semibold mb-2">Gates</h3>
22
+ <ul class="mb-4 list-disc pl-5">
23
+ <% flag.gates.each do |gate| %>
24
+ <li><code><%= gate["key"] %></code> = <code><%= gate["value"] %></code></li>
25
+ <% end %>
26
+ <% if flag.gates.empty? %>
27
+ <li>No gates (disabled)</li>
28
+ <% end %>
29
+ </ul>
30
+
31
+ <%= form_with url: admin_features_update_feature_path(key: flag.name), method: :post, local: true, class: "mb-6 space-y-3" do %>
32
+ <div>
33
+ <label>Percentage of actors (0-100)</label>
34
+ <%= number_field_tag :percentage, flag.gates.find { |gate| gate["key"] == "percentage_of_actors" }&.fetch("value", nil), min: 0, max: 100 %>
35
+ </div>
36
+ <div>
37
+ <label>Enable group</label>
38
+ <%= select_tag :group_name, options_for_select(Onni::Group.names), include_blank: true %>
39
+ <%= select_tag :group_action, options_for_select([%w[Enable enable], %w[Disable disable]]), include_blank: "Group action" %>
40
+ </div>
41
+ <div>
42
+ <label>A/B variants (name and weight)</label>
43
+ <div class="flex gap-2 items-end">
44
+ <div>
45
+ <label for="variant_control">control</label>
46
+ <%= number_field_tag "variants[control]", variants["control"], min: 0, max: 100, id: "variant_control" %>
47
+ </div>
48
+ <div>
49
+ <label for="variant_treatment">treatment</label>
50
+ <%= number_field_tag "variants[treatment]", variants["treatment"], min: 0, max: 100, id: "variant_treatment" %>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ <%= submit_tag "Save", class: "button" %>
55
+ <% end %>
56
+
57
+ <%= render partial: "admin/features/stats", locals: { chart: @hit_chart, flag: flag } %>
58
+
59
+ <p class="mt-4"><%= link_to "Back to features", admin_features_path %></p>
60
+ </div>
@@ -0,0 +1,6 @@
1
+ en:
2
+ onni:
3
+ menu: Features
4
+ active_admin:
5
+ features:
6
+ menu: Features
@@ -0,0 +1,8 @@
1
+ track_under:
2
+ - lib
3
+ - app
4
+ minimum_line_percent: 50
5
+ report_formats:
6
+ - html
7
+ - cobertura
8
+ - console
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module Onni
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ def self.next_migration_number(dirname)
14
+ next_number = Time.now.utc.strftime("%Y%m%d%H%M%S")
15
+ if Dir.exist?(dirname)
16
+ while Dir.children(dirname).any? { |name| name.start_with?(next_number) }
17
+ next_number = (next_number.to_i + 1).to_s
18
+ end
19
+ end
20
+ next_number
21
+ end
22
+
23
+ def copy_migration
24
+ migration_template "create_onni_features.rb.tt",
25
+ "db/migrate/create_onni_features.rb"
26
+ end
27
+
28
+ def create_features_yml
29
+ template "features.yml.tt", "config/features.yml"
30
+ end
31
+
32
+ def create_initializer
33
+ template "initializer.rb.tt", "config/initializers/onni.rb"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module Onni
7
+ module Generators
8
+ class StatsCleanupGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ def self.next_migration_number(dirname)
14
+ next_number = Time.now.utc.strftime("%Y%m%d%H%M%S")
15
+ if Dir.exist?(dirname)
16
+ while Dir.children(dirname).any? { |name| name.start_with?(next_number) }
17
+ next_number = (next_number.to_i + 1).to_s
18
+ end
19
+ end
20
+ next_number
21
+ end
22
+
23
+ def create_job
24
+ template "purge_stats_job.rb.tt", "app/jobs/onni_purge_stats_job.rb"
25
+ end
26
+
27
+ def create_period_start_index_migration
28
+ migration_template "add_onni_stats_period_start_index.rb.tt",
29
+ "db/migrate/add_onni_stats_period_start_index.rb"
30
+ end
31
+
32
+ def show_schedule_notes
33
+ say <<~MSG
34
+
35
+ Onni stats cleanup installed.
36
+
37
+ 1. Migrate: bin/rails db:migrate
38
+ 2. Set retention in config/initializers/onni.rb:
39
+ config.stats_retention = 90.days
40
+ 3. Schedule the job daily (host job backend or cron), for example:
41
+ OnniPurgeStatsJob.perform_later
42
+ 4. One-shot console drain (no queue):
43
+ Onni.purge_stats!(older_than: 90.days.ago)
44
+
45
+ See README "Stats growth and cleanup" for >1GB / >10M row guidance.
46
+ MSG
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddOnniStatsPeriodStartIndex < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ add_index :onni_feature_stat_buckets, :period_start,
6
+ name: "index_onni_feature_stats_on_period_start",
7
+ if_not_exists: true
8
+ end
9
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateOnniFeatures < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :onni_features do |t|
6
+ t.string :key, null: false
7
+ t.timestamps
8
+ end
9
+ add_index :onni_features, :key, unique: true
10
+
11
+ create_table :onni_feature_gates do |t|
12
+ t.string :feature_key, null: false
13
+ t.string :key, null: false
14
+ t.text :value, null: false
15
+ t.timestamps
16
+ end
17
+ add_index :onni_feature_gates, [:feature_key, :key, :value],
18
+ unique: true, name: "index_onni_feature_gates_unique"
19
+
20
+ create_table :onni_feature_stat_buckets do |t|
21
+ t.string :feature_key, null: false
22
+ t.string :period, null: false, default: "hour"
23
+ t.datetime :period_start, null: false
24
+ t.bigint :enabled_hits, null: false, default: 0
25
+ t.bigint :disabled_hits, null: false, default: 0
26
+ t.timestamps
27
+ end
28
+ add_index :onni_feature_stat_buckets, [:feature_key, :period, :period_start],
29
+ unique: true, name: "index_onni_feature_stats_unique"
30
+ add_index :onni_feature_stat_buckets, :period_start,
31
+ name: "index_onni_feature_stats_on_period_start"
32
+ end
33
+ end
34
+
@@ -0,0 +1,9 @@
1
+ ---
2
+ # new_checkout:
3
+ # expires_at: "2027-01-01"
4
+ # description: "Checkout redesign experiment"
5
+ # variants:
6
+ # control: 50
7
+ # treatment: 50
8
+ #
9
+ # Do not use Onni flags as authorization. Keep access control in your policy layer.
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ Onni.configure do |config|
4
+ config.features_path = Rails.root.join("config/features.yml")
5
+ config.cache = Rails.cache
6
+ config.cache_ttl = 15.minutes
7
+ config.stats_enabled = true
8
+ config.stats_sample_rate = 1.0 # float 0.0..1.0 (clamped); 1.0 skips Random
9
+ config.stats_scale_sampled_hits = true # kept samples increment by ~1/rate
10
+ # config.stats_buffer = true # coalesce hits until Onni.reset_request! / flush_stats!
11
+ # config.stats_ignore = %w[health_probe_flag]
12
+ # config.stats_sampler = ->(ctx) { ctx[:name] == "busy_flag" ? 0.01 : nil }
13
+ config.stats_retention = 90.days
14
+ # Per request: Onni.preload! then Onni.reset_request! in middleware / around_action
15
+ # :mutex (default) is safe across threads and fibers on Ruby 3.4+; :none skips locks.
16
+ config.concurrency = :mutex
17
+ config.adapter = Rails.env.test? ? :memory : :active_record
18
+ # Who may open the Onni ActiveAdmin dashboard (not app authorization):
19
+ # config.admin_authorized = -> { current_admin_user&.can_manage_experiments? }
20
+ end
21
+
22
+ # Hit-bucket cleanup (install job with: bin/rails generate onni:stats_cleanup)
23
+ # Schedule daily, e.g. OnniPurgeStatsJob.perform_later
24
+ # Or drain in console: Onni.purge_stats!(older_than: 90.days.ago)
25
+
26
+ # Audience targeting for rollouts — not access control.
27
+ Onni.register_group(:beta_testers) do |actor, _|
28
+ actor.respond_to?(:beta_tester?) && actor.beta_tester?
29
+ end
30
+
31
+ # One-time import from another feature-flag library (safe to re-run; skips unknown YAML keys
32
+ # and unsupported gates). Pass any source with #feature_keys and #gates, e.g. Migrator::Snapshot:
33
+ # Onni.migrate!(Onni::Migrator::Snapshot.new(feature_keys: [...], gates: [...]))
34
+
35
+ # Optional explicit ActiveAdmin support:
36
+ # require "onni/active_admin"
37
+
38
+ # Optional ops subscribe (ActiveSupport::Notifications):
39
+ # ActiveSupport::Notifications.subscribe(/\.onni$/) do |event|
40
+ # Rails.logger.info([event.name, event.payload].inspect)
41
+ # end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Deletes Onni hourly hit buckets older than config.stats_retention (or older_than).
4
+ # Schedule daily. Console drain without a queue: Onni.purge_stats!(older_than: 90.days.ago)
5
+ class OnniPurgeStatsJob < ApplicationJob
6
+ queue_as :low
7
+
8
+ def perform(older_than: nil, batch_size: 1_000, after_id: 0, processed_count: 0)
9
+ cutoff = older_than.present? ? Time.iso8601(older_than) : nil
10
+
11
+ Onni::Stats::Purge.call(
12
+ older_than: cutoff,
13
+ batch_size: batch_size,
14
+ after_id: after_id,
15
+ processed_count: processed_count,
16
+ enqueue: ->(**kwargs) {
17
+ self.class.perform_later(
18
+ older_than: kwargs[:older_than].iso8601,
19
+ batch_size: kwargs[:batch_size],
20
+ after_id: kwargs[:after_id],
21
+ processed_count: kwargs[:processed_count]
22
+ )
23
+ }
24
+ )
25
+ end
26
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # First-class ActiveAdmin support for Onni.
4
+ # The Rails engine loads `app/admin` when ActiveAdmin is already defined.
5
+ # Require this file explicitly when you want to assert ActiveAdmin is present:
6
+ #
7
+ # require "onni/active_admin"
8
+ #
9
+ require "onni"
10
+
11
+ raise LoadError, "ActiveAdmin is required for onni/active_admin" unless defined?(ActiveAdmin)
12
+
13
+ require "onni/engine" if defined?(Rails::Engine)
data/lib/onni/actor.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ module Actor
5
+ module_function
6
+
7
+ def id(actor)
8
+ return if actor.nil?
9
+ return actor.feature_actor_id if actor.respond_to?(:feature_actor_id)
10
+
11
+ "#{actor.class.name};#{actor.id}"
12
+ end
13
+
14
+ def percentage_value(actor, feature_key)
15
+ raw = id(actor).to_s
16
+ Zlib.crc32("#{feature_key}:#{raw}") % 100
17
+ end
18
+
19
+ def variant_for(actor, feature_key, variants)
20
+ return if actor.nil? || variants.blank?
21
+
22
+ score = percentage_value(actor, feature_key)
23
+ cursor = 0
24
+ variants.each do |name, weight|
25
+ cursor += weight.to_i
26
+ return name.to_sym if score < cursor
27
+ end
28
+ variants.keys.last&.to_sym
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ module Adapters
5
+ class ActiveRecord
6
+ def add(key)
7
+ ::Onni::Feature.find_or_create_by!(key: key.to_s)
8
+ end
9
+
10
+ def exist?(key)
11
+ ::Onni::Feature.where(key: key.to_s).exists? ||
12
+ ::Onni::Gate.where(feature_key: key.to_s).exists?
13
+ end
14
+
15
+ def feature_keys
16
+ (
17
+ ::Onni::Feature.pluck(:key) +
18
+ ::Onni::Gate.distinct.pluck(:feature_key)
19
+ ).uniq.sort
20
+ end
21
+
22
+ def gates_for(key)
23
+ ::Onni::Gate.where(feature_key: key.to_s).map { |gate| {"key" => gate.key, "value" => gate.value} }
24
+ end
25
+
26
+ def gates_for_many(keys)
27
+ keys = keys.map(&:to_s)
28
+ grouped = ::Onni::Gate.where(feature_key: keys).group_by(&:feature_key)
29
+ keys.each_with_object({}) do |key, loaded|
30
+ loaded[key] = Array(grouped[key]).map { |gate| {"key" => gate.key, "value" => gate.value} }
31
+ end
32
+ end
33
+
34
+ def replace_gates(key, gates)
35
+ ::Onni::Gate.transaction do
36
+ ::Onni::Gate.where(feature_key: key.to_s).delete_all
37
+ gates.each { |gate| add_gate(key, gate[:key] || gate["key"], gate[:value] || gate["value"]) }
38
+ end
39
+ end
40
+
41
+ def add_gate(key, gate_key, value)
42
+ ::Onni::Gate.find_or_create_by!(feature_key: key.to_s, key: gate_key.to_s, value: value.to_s)
43
+ end
44
+
45
+ def remove_gate(key, gate_key, value = nil)
46
+ scope = ::Onni::Gate.where(feature_key: key.to_s, key: gate_key.to_s)
47
+ scope = scope.where(value: value.to_s) unless value.nil?
48
+ scope.delete_all
49
+ end
50
+
51
+ def clear_gates(key)
52
+ ::Onni::Gate.where(feature_key: key.to_s).delete_all
53
+ end
54
+
55
+ def record_stat(key, enabled:, weight: 1)
56
+ weight = weight.to_i.clamp(1, Stats::Sampler::MAX_WEIGHT)
57
+ upsert_stat_row(key.to_s, enabled: enabled, weight: weight)
58
+ end
59
+
60
+ def record_stats_batch(buckets)
61
+ ::Onni::StatBucket.transaction do
62
+ buckets.each do |feature_key, counts|
63
+ enabled_weight = counts[:enabled].to_i
64
+ disabled_weight = counts[:disabled].to_i
65
+ upsert_stat_row(feature_key, enabled: true, weight: enabled_weight) if enabled_weight.positive?
66
+ upsert_stat_row(feature_key, enabled: false, weight: disabled_weight) if disabled_weight.positive?
67
+ end
68
+ end
69
+ buckets.size
70
+ end
71
+
72
+ def stats_for(key, period: "hour", from: 24.hours.ago, to: Time.current)
73
+ ::Onni::StatBucket.where(feature_key: key.to_s, period: period, period_start: from..to)
74
+ .order(:period_start).map do |bucket|
75
+ {
76
+ "period_start" => bucket.period_start,
77
+ "period" => bucket.period,
78
+ "enabled_hits" => bucket.enabled_hits,
79
+ "disabled_hits" => bucket.disabled_hits
80
+ }
81
+ end
82
+ end
83
+
84
+ def purge_stats_batch(older_than:, batch_size:, after_id: 0)
85
+ ids = ::Onni::StatBucket
86
+ .where(period_start: ...older_than)
87
+ .where("id > ?", after_id)
88
+ .order(:id)
89
+ .limit(batch_size)
90
+ .pluck(:id)
91
+ return {deleted: 0, last_id: nil} if ids.empty?
92
+
93
+ deleted = ::Onni::StatBucket.where(id: ids).delete_all
94
+ {deleted: deleted, last_id: ids.last}
95
+ end
96
+
97
+ def stats_overview
98
+ {
99
+ bucket_count: ::Onni::StatBucket.count,
100
+ oldest_period_start: ::Onni::StatBucket.minimum(:period_start)
101
+ }
102
+ end
103
+
104
+ private
105
+
106
+ def upsert_stat_row(key, enabled:, weight:)
107
+ weight = weight.to_i.clamp(1, Stats::Sampler::MAX_WEIGHT)
108
+ period_start = Time.current.utc.beginning_of_hour
109
+ now = Time.current
110
+ column = enabled ? "enabled_hits" : "disabled_hits"
111
+ # Intent: column conflict target matches index_onni_feature_stats_unique (SQLite + PostgreSQL ON CONFLICT).
112
+ ::Onni::StatBucket.upsert(
113
+ {
114
+ feature_key: key.to_s,
115
+ period: "hour",
116
+ period_start: period_start,
117
+ enabled_hits: enabled ? weight : 0,
118
+ disabled_hits: enabled ? 0 : weight,
119
+ created_at: now,
120
+ updated_at: now
121
+ },
122
+ unique_by: %i[feature_key period period_start],
123
+ on_duplicate: ::Arel.sql("#{column} = #{column} + #{weight}, updated_at = excluded.updated_at")
124
+ )
125
+ end
126
+ end
127
+ end
128
+ end