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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4adac16f882a9d8562e9441200c4e11f1a7c4dc49d0e577a2d0620ce11db1789
4
+ data.tar.gz: 95e396d99bf6a7f609fd4b6500d8db02ddf80103c07ce1d13b94e74672a71147
5
+ SHA512:
6
+ metadata.gz: b234b572464676742136aa0b26a006408fdd0907189b3098a1aa794033309a9180b1bb763c7bc86d39c48bde47ff578b5793529ccd2d475906c4f6a2db8e5f03
7
+ data.tar.gz: '069c548695c4ebeb0a89868ef4351a09a06308199c819f83241a31f03c3e1d1823966b90e5dd4bee418b41489a72ef5f2b0b082a09dfb5562abbcf6154b4f223'
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # CHANGELOG
2
+
3
+ ## 0.1.0 (2026-11-12)
4
+
5
+ - Add feature evaluation charts with 24-hour, 7-day, and 30-day history, enabled share, expandable counts, and replaceable views
6
+ - Initial gem: YAML registry, runtime gates, groups, percentage rollout, A/B variants, hit stats
7
+ - Solid core usable in any Ruby app (memory adapter by default)
8
+ - Optional ActiveRecord adapter and Rails engine
9
+ - First-class pluggable ActiveAdmin dashboard support
10
+ - Pluggable migrator (`Onni.migrate!`) for importing gates from another feature-flag library
11
+ - Fail closed on past or unparseable `expires_at` for `enabled?` / `variant`
12
+ - Allow `register_group` from the boot empty registry (no prior `clear!` required)
13
+ - Record ActiveRecord hit stats with a unique-index upsert (avoids first-hit create races)
14
+ - Add `record_stats:` on `enabled?` / `variant` / `disabled?`; ActiveAdmin index inspects without recording hits
15
+ - Import `variants` gates via `Onni.migrate!`
16
+ - Add `Onni.purge_stats!` / `Onni::Stats::Purge` with batched retention cleanup
17
+ - Add `rails g onni:stats_cleanup` (job + `period_start` index) and README growth guidance
18
+ - Default `config.stats_retention = 90.days`
19
+ - Stats sampling: clamp `stats_sample_rate` to 0.0..1.0, skip Random at 1.0, optional `stats_sampler` / `stats_ignore`, scale kept samples by ~1/rate (`stats_scale_sampled_hits`)
20
+ - Add `ActiveSupport::Notifications` (`*.onni`) for stats record/purge and enable/disable
21
+ - Add `Onni.stats_overview` and ActiveAdmin Hit storage panel (retention / cleanup signals)
22
+ - Add configurable `concurrency` (`:mutex` default for threads/fibers, `:none` to skip locks)
23
+ - Document that Onni is for experiments and stats - not authorization
24
+ - Pressure binding and L1 load ceilings for the memory all-features evaluate path (boolean, actor, percentage, group, variant, conditions, stats)
25
+ - Add request preload (`Onni.preload!` / `gates_for_many`) and optional `stats_buffer` with `flush_stats!` / `reset_request!`
26
+ - Lean `Store.variant` reuses one gates fetch (no second adapter read / Flag allocate)
27
+ - Instrument group/percentage/variants mutations; ActiveAdmin show shows hit sample rate; `onni:install` smoke spec; expanded RBS; upsert dialect note
28
+ - First-class SQLite support: shared file DB in dummy, column `unique_by` upsert conflict target, pool/ON CONFLICT specs, host timeout/WAL notes
29
+ - Rails dummy + request specs for ActiveRecord adapter and ActiveAdmin Features page
30
+ - Unit specs for Group, Registry, Actor, Conditions, and Flag
@@ -0,0 +1,26 @@
1
+ # Code of Conduct
2
+
3
+ ## Pledge
4
+
5
+ Participation is a harassment-free experience. Treat each other with respect. Keep private context private.
6
+
7
+ ## Expected behavior
8
+
9
+ - Engage with genuine curiosity. Admit uncertainty.
10
+ - Own every line you submit, including generated code.
11
+ - Correct errors politely.
12
+ - Use language that welcomes people and respects identity and privacy.
13
+
14
+ ## Unacceptable behavior
15
+
16
+ - Harassment, trolling, or insulting comments, public or private.
17
+ - Using jargon or volume, including automated spam, to silence others.
18
+ - Sharing others' data or personal context without permission.
19
+
20
+ ## Automation
21
+
22
+ You remain responsible for bugs, security holes, and bias in generated artifacts you submit.
23
+
24
+ ## Enforcement
25
+
26
+ Report abusive or harassing behavior to contact@kiskolabs.com. Reports are reviewed and investigated.
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,23 @@
1
+ # Contributing Guidelines
2
+
3
+ ## Automation authorship
4
+
5
+ You may use AI and automation tools. You remain the author of every line you submit: review, debug, and understand the change.
6
+
7
+ Keep pull requests small enough to review. Annotate non-obvious logic.
8
+
9
+ Never send project secrets or private context to public models.
10
+
11
+ ## How to contribute
12
+
13
+ ### Reporting issues
14
+
15
+ Verify the facts before filing. Use issue templates for goals, constraints, and reproduction steps.
16
+
17
+ ### Pull requests
18
+
19
+ Keep one pull request to one goal. Explain why the change is needed. Run smoke tests and regression checks locally before opening the request.
20
+
21
+ ### Review
22
+
23
+ Reviewers will ask about approach and trade-offs. Treat suggested changes as part of the work.
data/GOVERNANCE.md ADDED
@@ -0,0 +1,25 @@
1
+ # Project Governance
2
+
3
+ ## Roles
4
+
5
+ ### Contributors
6
+
7
+ Anyone who submits code, documentation, or participates in discussions. Contributors take ownership of problems they identify and follow the project's quality standards.
8
+
9
+ ### Maintainers
10
+
11
+ Maintainers:
12
+
13
+ 1. Set scope and architectural direction.
14
+ 2. Review contributions (human or automated) against security and logic standards.
15
+ 3. Decide when the community is divided.
16
+
17
+ ## Decision making
18
+
19
+ ### Lazy consensus
20
+
21
+ For routine changes, a proposal that draws no objection within two weeks is approved.
22
+
23
+ ### Maintainer approval
24
+
25
+ Major architectural changes, high-risk automation integrations, or changes that affect business logic need explicit maintainer approval. A change must solve a real problem.
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 amkisko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,301 @@
1
+ # onni
2
+
3
+ [![Test Status](https://github.com/amkisko/onni.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/onni.rb/actions/workflows/test.yml)
4
+
5
+ Feature flags, gradual rollout, A/B variants, audience targeting groups, and hit stats for any Ruby app. Optional ActiveRecord persistence and first-class pluggable ActiveAdmin support.
6
+
7
+ ## What Onni is for
8
+
9
+ Use Onni to:
10
+
11
+ - turn product experiments and incomplete surfaces on or off
12
+ - roll a change out to a percentage of actors or a named audience group
13
+ - assign A/B (or multi-arm) variants
14
+ - collect enabled/disabled hit stats for analysis
15
+
16
+ ## What Onni is not for
17
+
18
+ **Do not use Onni as an authorization or access-control system.**
19
+
20
+ `Onni.enabled?` answers “is this flag on for this actor for experiment / rollout purposes?”, not “may this principal perform this privileged action?”. Keep authentication and authorization in your policy layer (for example Pundit, CanCanCan, or host middleware). A flag may *gate whether a UI is offered*; it must not be the only check that protects a write, read of private data, or admin capability.
21
+
22
+ YAML `conditions` and registered groups are targeting helpers for rollouts. They are not a substitute for object-, function-, or tenant-level authorization.
23
+
24
+ ## Requirements
25
+
26
+ - Ruby 3.4+
27
+ - ActiveSupport 7.2.3.2+
28
+ - Optional: Rails 7.2.3.2+ (engine, ActiveRecord adapter)
29
+ - Optional: ActiveAdmin 4.0.0.beta23+ (admin dashboard)
30
+
31
+ ## Install
32
+
33
+ From RubyGems after a release:
34
+
35
+ ```ruby
36
+ # Gemfile
37
+ gem "onni"
38
+ ```
39
+
40
+ From git:
41
+
42
+ ```ruby
43
+ # Gemfile
44
+ gem "onni", github: "amkisko/onni.rb"
45
+ ```
46
+
47
+ Rails + ActiveRecord:
48
+
49
+ ```sh
50
+ bin/rails generate onni:install
51
+ bin/rails db:migrate
52
+ ```
53
+
54
+ ActiveAdmin dashboard loads automatically when ActiveAdmin is present. You can also require it explicitly:
55
+
56
+ ```ruby
57
+ require "onni/active_admin"
58
+ ```
59
+
60
+ ## YAML registry
61
+
62
+ Every flag must be listed in `config/features.yml`:
63
+
64
+ ```yaml
65
+ new_checkout:
66
+ expires_at: "2027-06-01"
67
+ description: "Checkout redesign experiment"
68
+ variants:
69
+ control: 50
70
+ treatment: 50
71
+ ```
72
+
73
+ Unknown names fail closed: `enabled?` returns false; `enable` / `disable` raise `KeyError`.
74
+ Past or unparseable `expires_at` values fail closed on `enabled?` / `variant`. The ActiveAdmin UI still shows the expiry date.
75
+
76
+ Optional YAML `conditions` (checked after a gate matches, when an actor is present). These narrow a rollout audience; they are not authorization:
77
+
78
+ ```yaml
79
+ search_v2:
80
+ expires_at: "2027-06-01"
81
+ description: "New search UI for beta testers"
82
+ conditions:
83
+ - beta_tester?
84
+ - beta_testers # registered Onni group name
85
+ ```
86
+
87
+ ## Runtime API
88
+
89
+ ```ruby
90
+ Onni.enabled?(:new_checkout, user)
91
+ Onni.enabled?(:new_checkout, record_stats: false) # inspect without recording a hit
92
+ Onni.enable(:new_checkout)
93
+ Onni.enable(:new_checkout, user)
94
+ Onni.disable(:new_checkout)
95
+ Onni.enable_group(:search_v2, :beta_testers)
96
+ Onni.enable_percentage_of_actors(:homepage_banner, 25)
97
+ Onni.set_variants(:new_checkout, {control: 50, treatment: 50})
98
+ Onni.variant(:new_checkout, user) # => :control / :treatment
99
+ Onni.register_group(:beta_testers) { |actor, _| actor.beta_tester? }
100
+ ```
101
+
102
+ Gates: `boolean`, `actors`, `groups`, `percentage_of_actors`, `variants`.
103
+
104
+ `Onni.exist?(name)` is adapter-backed (gates/feature rows). `Onni::Flag#exist?` is YAML-registry-backed.
105
+
106
+ ## Stats
107
+
108
+ Each `enabled?` / `variant` evaluation can record enabled/disabled hits into hourly buckets. Pass `record_stats: false` for inspect-only checks (the ActiveAdmin index does this). View samples on `/admin/features` for analysis. Stats are operational telemetry for experiments, not an audit log for authorization decisions.
109
+
110
+ Rows are created only when a hit is recorded for that flag and hour. Quiet hours leave no row.
111
+
112
+ ### Instrumentation
113
+
114
+ Onni publishes `ActiveSupport::Notifications` events:
115
+
116
+ - `stats.record.onni` — a kept hit write (`:name`, `:enabled`, `:weight`)
117
+ - `stats.purge.onni` — purge batch/drain result (`:deleted`, `:processed_count`, `:continued`)
118
+ - `feature_operation.onni` — mutations (`:feature_name`, `:operation`, `:result`; optional `:actor_id`, `:group_name`, `:percentage`). Operations: `enable`, `disable`, `enable_group`, `disable_group`, `enable_percentage_of_actors`, `set_variants`
119
+
120
+ ```ruby
121
+ ActiveSupport::Notifications.subscribe(/\.onni$/) do |event|
122
+ Rails.logger.info([event.name, event.payload].inspect)
123
+ end
124
+ ```
125
+
126
+ `Onni.stats_overview` (also on the ActiveAdmin Features page) reports bucket count, oldest hour, retention, and whether cleanup is overdue.
127
+
128
+ ### Sampling
129
+
130
+ - `stats_sample_rate` is a float in `0.0..1.0` (clamped on assign). `1.0` records everything and skips `Random`.
131
+ - `stats_sampler` is an optional callable `|ctx|` with `ctx[:name]` and `ctx[:enabled]`; return a rate, `true`/`false`, or `nil` to fall back to `stats_sample_rate`.
132
+ - `stats_ignore` lists feature keys that never write buckets.
133
+ - `stats_scale_sampled_hits` (default `true`): when a sample is kept at rate `r`, the bucket increments by about `1/r` so totals estimate full traffic. Set `false` to store raw kept-sample counts only.
134
+
135
+ ```ruby
136
+ Onni.configure do |config|
137
+ config.stats_sample_rate = 0.1
138
+ config.stats_ignore = %w[noisy_probe]
139
+ config.stats_sampler = ->(ctx) { ctx[:name] == "checkout_v2" ? 1.0 : nil }
140
+ end
141
+ ```
142
+
143
+ ### Request preload and stats buffer
144
+
145
+ Hosts that evaluate many flags per request can cut adapter reads and coalesce hit writes:
146
+
147
+ ```ruby
148
+ # Once per request (middleware / around_action), after configure:
149
+ Onni.preload! # all YAML keys, or Onni.preload!(:new_checkout, :search_v2)
150
+ Onni.configuration.stats_buffer = true
151
+
152
+ # ... enabled? / variant calls use the fiber-local gate map ...
153
+
154
+ Onni.reset_request! # flushes buffered stats (when enabled) and clears preload
155
+ # or: Onni.flush_stats!
156
+ ```
157
+
158
+ `gates_for_many` loads ActiveRecord gates in one `WHERE feature_key IN (...)` query. Memory adapter uses one lock section. Preload is fiber-local (safe with threads and fibers on Ruby 3.4+).
159
+
160
+ ### Stats growth and cleanup
161
+
162
+ Default retention is `config.stats_retention = 90.days`. Install a batched cleanup job and a `period_start` index:
163
+
164
+ ```sh
165
+ bin/rails generate onni:stats_cleanup
166
+ bin/rails db:migrate
167
+ ```
168
+
169
+ Schedule daily (host job backend or cron):
170
+
171
+ ```ruby
172
+ OnniPurgeStatsJob.perform_later
173
+ ```
174
+
175
+ One-shot drain without a queue (pages until done in-process):
176
+
177
+ ```ruby
178
+ Onni.purge_stats!(older_than: 90.days.ago)
179
+ # or: Onni::Stats.purge!(batch_size: 1_000)
180
+ ```
181
+
182
+ Rough size: rows grow with `(flags that see traffic) x (hours with at least one recorded hit)`. Sampling (`stats_sample_rate`) and shorter retention shrink that. Prefer `stats_ignore` or a low `stats_sampler` rate for very hot flags before lengthening retention alone.
183
+
184
+ When the table is already large (about **>1GB** or **>10M rows**):
185
+
186
+ 1. Schedule or run purge immediately; keep `stats_retention` at a value you actually query (admin default window is 24h).
187
+ 2. Lower `stats_sample_rate` if you only need trend signal, not full counts.
188
+ 3. After a large delete, run your database's maintenance (`VACUUM` on SQLite, `VACUUM` / `ANALYZE` on PostgreSQL, `OPTIMIZE TABLE` where relevant).
189
+ 4. **Partitioning** (for example range partitions on `period_start`) is a host DDL choice. Onni does not manage partitions; keep the unique key `(feature_key, period, period_start)` and the purge predicate (`period_start < cutoff`) efficient. Dropping old partitions can replace row-by-row delete once you partition.
190
+ 5. If stats are not worth the write load, set `stats_enabled = false` and truncate or drop the table.
191
+
192
+ ### SQLite
193
+
194
+ SQLite is a first-class ActiveRecord dialect for Onni (dummy app, install migration, and CI). Hit writes use `INSERT ... ON CONFLICT DO UPDATE` with `excluded.updated_at` on the unique key `(feature_key, period, period_start)`.
195
+
196
+ Host tips:
197
+
198
+ - Prefer a file database, or `file:... ?mode=memory&cache=shared`. Plain `:memory:` gives each connection-pool checkout a private empty database, so upserts fail with a missing unique index.
199
+ - Set `timeout: 5000` (milliseconds) in `database.yml` so writers wait on locks under concurrent checkouts.
200
+ - For multi-process writers, enable WAL in the host (`PRAGMA journal_mode=WAL`).
201
+ - After a large purge, run `VACUUM`.
202
+
203
+ ### Other ActiveRecord dialects
204
+
205
+ PostgreSQL uses the same `excluded.*` upsert form. If a host dialect rejects `excluded.*`, override `Adapters::ActiveRecord#record_stat` / `#record_stats_batch` or patch the `on_duplicate` fragment for that adapter.
206
+
207
+ ## ActiveAdmin dashboard access
208
+
209
+ ### Hit charts
210
+
211
+ Each feature detail page includes enabled/disabled evaluation charts, totals,
212
+ enabled share, and an expandable data table. Choose Last 24 hours (hourly),
213
+ Last 7 days, or Last 30 days (daily). Times are UTC and include the current
214
+ incomplete period. The chart needs no JavaScript setup.
215
+
216
+ Counts describe evaluations, not unique users or A/B conversion results.
217
+ Sampling can produce estimates or raw sample counts. Periods without recorded
218
+ evaluations appear as zero; retention may have removed older history.
219
+
220
+ To replace the chart, add `app/views/admin/features/_chart.html.erb` in your
221
+ application. It receives an `Onni::HitChart` as the `chart` local:
222
+
223
+ ```erb
224
+ <p>Enabled evaluations: <%= chart.enabled_hits %></p>
225
+ <p>Disabled evaluations: <%= chart.disabled_hits %></p>
226
+ ```
227
+
228
+ Use `chart.rows` for chronological points (`period_start`, `enabled_hits`,
229
+ `disabled_hits`, all string keys), `chart.hourly?` for the interval, and
230
+ `chart.enabled_percentage` for the enabled share (`nil` without evaluations).
231
+ You can replace `app/views/admin/features/_stats.html.erb` for the entire
232
+ statistics section; it receives `chart` and `flag` locals. Onni invokes the
233
+ chart partial only when the selected window contains recorded evaluations.
234
+
235
+ ### Dashboard authorization
236
+
237
+ Menu visibility and every Features page action run `admin_authorized` (instance_exec on the page controller). That callback only controls who may open the Onni dashboard in ActiveAdmin. It does not authorize application actions gated by flags. Default is allow-all — set it in the initializer:
238
+
239
+ ```ruby
240
+ Onni.configure do |config|
241
+ config.admin_authorized = -> { current_admin_user&.can_manage_experiments? }
242
+ end
243
+ ```
244
+
245
+ Hosts that use Pundit/CanCanCan should also authorize ActiveAdmin page actions (`enable?`, `disable?`, `feature?`, `update_feature?`) for the Features page.
246
+
247
+ ## Concurrency
248
+
249
+ Shared in-process state (memory adapter, audience groups, YAML registry cache) uses configurable locking:
250
+
251
+ ```ruby
252
+ Onni.configure do |config|
253
+ config.concurrency = :mutex # default; covers threads and fibers on Ruby 3.4+
254
+ # config.concurrency = :none # single-threaded hosts / benchmarks
255
+ end
256
+ ```
257
+
258
+ Set concurrency before the adapter is built (or call `Onni.reset_adapter!` after changing it). Do not `Fiber.yield` inside Onni critical sections.
259
+
260
+ ## Test adapter
261
+
262
+ ```ruby
263
+ Onni.configure { |config| config.adapter = :memory }
264
+ Onni.adapter = Onni::Adapters::Memory.new
265
+ Onni.enable(:new_checkout)
266
+ ```
267
+
268
+ ## Migrating from another feature-flag library
269
+
270
+ Map your previous store into a source with `#feature_keys` and `#gates`, then import once. Keep `config/features.yml` keys aligned with the names you import. Unknown YAML keys are skipped. Unsupported gate keys are counted in `skipped_gates` and not written.
271
+
272
+ ```ruby
273
+ source = Onni::Migrator::Snapshot.new(
274
+ feature_keys: %w[new_checkout search_v2],
275
+ gates: [
276
+ {feature_key: "new_checkout", key: "boolean", value: "true"},
277
+ {feature_key: "search_v2", key: "percentage_of_actors", value: "25"},
278
+ {feature_key: "search_v2", key: "groups", value: "beta_testers"},
279
+ {feature_key: "search_v2", key: "actors", value: "User;42"}
280
+ ]
281
+ )
282
+
283
+ Onni.migrate!(source)
284
+ # => { features:, gates:, skipped_gates: }
285
+ ```
286
+
287
+ Any object that responds to `#feature_keys` and `#gates` works as a source; `Snapshot` is the built-in in-memory shape. Gate rows accept String or Symbol keys for `feature_key`, `key`, and `value`.
288
+
289
+ Imported gate keys: `boolean`, `actors`, `groups`, `percentage_of_actors`, `variants`.
290
+ Other gate keys from the prior library are skipped. YAML `variants` still win over an imported variants gate when both are present.
291
+ Replace call sites with `Onni.*` and remove the previous library when ready.
292
+
293
+ ## Development
294
+
295
+ ```bash
296
+ bundle install
297
+ bundle exec appraisal install
298
+ make test
299
+ ```
300
+
301
+ Maintainers publish with `make release` (`usr/bin/release.rb`).
data/SECURITY.md ADDED
@@ -0,0 +1,25 @@
1
+ # SECURITY
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ Email security details to: security@kiskolabs.com
6
+
7
+ Send the report only to that address. Include description, steps to reproduce, potential impact, and a suggested fix when you have one.
8
+
9
+ ### Response Timeline
10
+
11
+ - We will acknowledge receipt of your report.
12
+ - We will provide an initial assessment.
13
+ - We will keep you informed of our progress and resolution timeline.
14
+
15
+ ### Disclosure Policy
16
+
17
+ - We will work with you to understand and resolve the issue.
18
+ - We will credit you for the discovery unless you prefer to remain anonymous.
19
+ - We will publish a security advisory after the vulnerability is patched.
20
+ - We will coordinate public disclosure with you.
21
+
22
+ ## Automation Security
23
+
24
+ - Keep production credentials, API keys, and personal data out of prompts sent to third-party models or automation services.
25
+ - Verify automated dependencies.
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveAdmin.register_page "Features" do
4
+ menu priority: 3, label: proc { I18n.t("onni.menu", default: "Features") },
5
+ if: proc { instance_exec(&Onni.configuration.admin_authorized) }
6
+
7
+ controller do
8
+ before_action :onni_require_admin!
9
+
10
+ private
11
+
12
+ def onni_require_admin!
13
+ return if instance_exec(&Onni.configuration.admin_authorized)
14
+
15
+ head :forbidden
16
+ end
17
+ end
18
+
19
+ content title: "Features" do
20
+ flags = Onni::Registry.config.keys.sort.map { |key| Onni::Flag.new(key) }
21
+ overview = Onni.stats_overview
22
+
23
+ panel "Dashboard" do
24
+ para "Experiments and feature flags: actor/group targeting, percentage rollout, A/B variants, conditions, and hit stats for analysis."
25
+ para "Onni is not an authorization system. Protect privileged actions in your host policy layer."
26
+ para "Import from another feature-flag library with Onni.migrate!(source), then use the controls below."
27
+ end
28
+
29
+ panel "Hit storage" do
30
+ para "Buckets: #{overview[:bucket_count]}"
31
+ para "Oldest hour: #{overview[:oldest_period_start] || "—"}"
32
+ retention = overview[:retention]
33
+ para "Retention: #{retention ? "#{(retention / 1.day).to_i} days" : "not set"}"
34
+ para "Sample rate: #{overview[:sample_rate]}"
35
+ if overview[:past_retention]
36
+ para "Oldest hit history is past retention. Run scheduled cleanup."
37
+ end
38
+ if overview[:large_table]
39
+ para "Hit storage is large. Confirm daily cleanup is scheduled."
40
+ elsif overview[:bucket_count].positive?
41
+ para "Keep cleanup scheduled daily so hit history does not grow without bound."
42
+ else
43
+ para "No hit buckets yet. After traffic starts, schedule daily cleanup."
44
+ end
45
+ end
46
+
47
+ panel "Flags" do
48
+ table_for flags do
49
+ column("Key") { |flag| link_to flag.name, admin_features_feature_path(key: flag.name) }
50
+ column("Description", &:description)
51
+ column("Expires") { |flag| flag.expires_at }
52
+ column("Enabled globally") { |flag| status_tag(flag.enabled?(record_stats: false)) }
53
+ column("Gates") { |flag| flag.gates.map { |gate| "#{gate["key"]}=#{gate["value"]}" }.join(", ").presence || "—" }
54
+ column("Actions") do |flag|
55
+ span button_to("Enable", admin_features_enable_path(key: flag.name), method: :post)
56
+ span button_to("Disable", admin_features_disable_path(key: flag.name), method: :post)
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ page_action :enable, method: :post do
63
+ Onni.enable(params.require(:key))
64
+ redirect_to admin_features_path, notice: "Enabled #{params[:key]}"
65
+ end
66
+
67
+ page_action :disable, method: :post do
68
+ Onni.disable(params.require(:key))
69
+ redirect_to admin_features_path, notice: "Disabled #{params[:key]}"
70
+ end
71
+
72
+ page_action :feature, method: :get do
73
+ range = params.fetch(:range, "24h")
74
+ next head(:bad_request) unless Onni::HitChart::WINDOWS.key?(range)
75
+
76
+ @flag = Onni::Flag.new(params.require(:key))
77
+ @hit_chart = Onni::HitChart.new(@flag.name, range: range)
78
+ render "admin/features/show", layout: "active_admin"
79
+ end
80
+
81
+ page_action :update_feature, method: :post do
82
+ key = params.require(:key)
83
+ percentage = params[:percentage].presence
84
+ Onni.enable_percentage_of_actors(key, percentage.to_i) if percentage
85
+ if params[:variants].present?
86
+ variants = params[:variants].to_unsafe_h.transform_values(&:to_i).reject { |_name, weight| weight <= 0 }
87
+ Onni.set_variants(key, variants) if variants.any?
88
+ end
89
+ case params[:group_action]
90
+ when "enable"
91
+ Onni.enable_group(key, params.require(:group_name))
92
+ when "disable"
93
+ Onni.disable_group(key, params.require(:group_name))
94
+ end
95
+ redirect_to admin_features_feature_path(key: key), notice: "Updated #{key}"
96
+ end
97
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ class Feature < ::ActiveRecord::Base
5
+ self.table_name = "onni_features"
6
+
7
+ has_many :gates, class_name: "Onni::Gate", foreign_key: :feature_key, primary_key: :key,
8
+ dependent: :delete_all, inverse_of: :feature
9
+ has_many :stat_buckets, class_name: "Onni::StatBucket", foreign_key: :feature_key,
10
+ primary_key: :key, dependent: :delete_all, inverse_of: :feature
11
+
12
+ validates :key, presence: true, uniqueness: true
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ class Gate < ::ActiveRecord::Base
5
+ self.table_name = "onni_feature_gates"
6
+
7
+ belongs_to :feature, class_name: "Onni::Feature", foreign_key: :feature_key,
8
+ primary_key: :key, inverse_of: :gates, optional: true
9
+
10
+ validates :feature_key, :key, :value, presence: true
11
+ end
12
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ class HitChart
5
+ WINDOWS = {"24h" => 24, "7d" => 168, "30d" => 720}.freeze
6
+
7
+ attr_reader :range, :from, :to, :rows, :enabled_hits, :disabled_hits
8
+
9
+ def initialize(name, range: "24h")
10
+ raise ArgumentError, "Unsupported hit history range" unless WINDOWS.key?(range)
11
+
12
+ @range = range
13
+ @to = Time.current.utc
14
+ @from = period_start(to) - (period_count - 1) * interval
15
+ load_history(name)
16
+ end
17
+
18
+ def hourly?
19
+ range == "24h"
20
+ end
21
+
22
+ def enabled_percentage
23
+ total = enabled_hits + disabled_hits
24
+ enabled_hits.fdiv(total) * 100 if total.positive?
25
+ end
26
+
27
+ def maximum
28
+ [1, rows.flat_map { |row| row.values_at("enabled_hits", "disabled_hits") }.max].max
29
+ end
30
+
31
+ def points(column)
32
+ raise ArgumentError, "Unknown hit series" unless %w[enabled_hits disabled_hits].include?(column)
33
+
34
+ peak = maximum
35
+ rows.each_with_index.map do |row, index|
36
+ point(row.fetch(column), index, peak)
37
+ end.join(" ")
38
+ end
39
+
40
+ private
41
+
42
+ def load_history(name)
43
+ @rows = build_rows(Onni::Stats.for(name, from: from, to: to))
44
+ @enabled_hits = rows.sum { |row| row.fetch("enabled_hits") }
45
+ @disabled_hits = rows.sum { |row| row.fetch("disabled_hits") }
46
+ end
47
+
48
+ def interval
49
+ hourly? ? 1.hour : 1.day
50
+ end
51
+
52
+ def period_count
53
+ hourly? ? WINDOWS.fetch(range) : WINDOWS.fetch(range) / 24
54
+ end
55
+
56
+ def period_start(time)
57
+ hourly? ? time.utc.beginning_of_hour : time.utc.beginning_of_day
58
+ end
59
+
60
+ def point(value, index, peak)
61
+ x = 56 + 720 * index.fdiv(rows.size - 1)
62
+ y = 208 - 184 * value.fdiv(peak)
63
+ format("%.2f,%.2f", x, y)
64
+ end
65
+
66
+ def build_rows(buckets)
67
+ grouped = buckets.group_by { |bucket| period_start(bucket.fetch("period_start")) }
68
+ Array.new(period_count) do |index|
69
+ started = from + index * interval
70
+ period_row(started, grouped.fetch(started, []))
71
+ end
72
+ end
73
+
74
+ def period_row(started, samples)
75
+ {
76
+ "period_start" => started,
77
+ "enabled_hits" => samples.sum { |sample| sample.fetch("enabled_hits") },
78
+ "disabled_hits" => samples.sum { |sample| sample.fetch("disabled_hits") }
79
+ }
80
+ end
81
+ end
82
+ end