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
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
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -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
|
+
[](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
|