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,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,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,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
|