howdy/flags
Feature flags, defined in code and switched while the app runs.
A flag is declared in code with its default, so it works with nothing stored: in tests, on a fresh deployment, and on any node that has not heard of a change yet. A store keeps only what someone changed:
- a kill switch, which turns the flag off for everyone at once;
- users, organizations and groups it is blocked for, or allowed for;
- a rollout: the share of users (or organizations) who get it, from 0 to 100%, optionally raised step by step on a schedule.
pub fn new_checkout() -> flags.Flag {
flags.flag("new_checkout", description: "Stripe-hosted checkout")
}
let assert Ok(store) = flags_database.store(db)
let assert Ok(features) =
flags.new(store) |> flags.register([new_checkout()]) |> flags.start
case flags.enabled(features, new_checkout(), for: flags.user(user.id)) {
True -> new_checkout_page(ctx)
False -> checkout_page(ctx)
}
Checks read an in-memory copy of the settings and never touch the
store. Changes made on this node apply before the call that made them
returns; other nodes pick them up the next time they check for changes,
every two seconds unless check_every says otherwise.
In an app, put the keeper under a supervisor with supervised: the
handle it returns keeps working across restarts.
Stores
Where the settings are kept is up to a Store:
howdy/flags/database keeps them in a Gloo Repo, howdy/flags/memory
in one process, and store builds one over anything else, such as a
service that manages the flags for you. A store without a writer is
read-only: the flags are changed wherever it gets them from.
Rollouts
Everyone has a position from 0 to 99.99% for each flag, worked out from the flag’s key and their id, so it never changes and needs nothing stored. A rollout of 20% is on for everyone below 20%. Raising it only adds people; lowering it takes away the most recently added first, so rolling back from 20% to 5% leaves the first 5% as they were. Because the key is part of it, a different 5% go first for each flag.
Rolling back a flag changes which code runs, not the data the new code already wrote: the old path has to cope with it.
Management operations such as set_rollout are privileged: authorize
the caller first. Each is recorded in the flag’s history under the name
given as by, and can be undone.
Types
Who a flag is being checked for: a user, the organization they are
acting in, both, or neither. The ids are yours, such as a
howdy_auth user id.
pub opaque type Actor
What a rollout’s positions are worked out from. By organization, everyone in an organization is in or out together.
pub type Bucketing {
ByUser
ByOrganization
}
Constructors
-
ByUser -
ByOrganization
One recorded change. A change to a flag keeps the setting before and
after it (None when nothing was stored) and can be undone; group
changes have no flag. at is in unix seconds; ids rise in the order
changes were made.
pub type Change {
Change(
id: Int,
flag: option.Option(String),
at: Int,
by: String,
summary: String,
before: option.Option(Setting),
after: option.Option(Setting),
)
}
Constructors
-
Change( id: Int, flag: option.Option(String), at: Int, by: String, summary: String, before: option.Option(Setting), after: option.Option(Setting), )
Why a flag is on or off for someone.
pub type Decision {
Default(on: Bool)
Killed
Blocked(Target)
Allowed(Target)
InRollout(position: Int, rollout: Int)
OutsideRollout(position: Int, rollout: Int)
NoPosition(Bucketing)
}
Constructors
-
Default(on: Bool)Nothing is stored for the flag; its default applies.
-
KilledThe kill switch is on.
-
Blocked(Target)A block rule matched, for this target.
-
Allowed(Target)An allow rule matched, for this target.
-
InRollout(position: Int, rollout: Int)Their position is below the rollout. Both are in hundredths of a percent.
-
OutsideRollout(position: Int, rollout: Int)Their position is at or above the rollout.
-
NoPosition(Bucketing)The rollout is by user or organization, and they have none, so they only get the flag at 100%.
A flag as the code knows it: its key, what it is for, and whether it is on when nothing has been stored for it.
pub opaque type Flag
Running flags: the settings in memory, and a process, the keeper, that
keeps them current. Both are found by names chosen when the handle is
made, not by the keeper’s pid, so the handle outlives any one keeper:
a keeper restarted by a supervisor publishes under the same key and
registers the same name, and checks, refresh and stop carry on.
pub opaque type Flags
A rollout raised a step at a time. steps are rollouts in hundredths of
a percent, rising; the next one is taken every seconds, at next (unix
seconds). A paused ramp has no next.
pub type Ramp {
Ramp(steps: List(Int), every: Int, next: option.Option(Int))
}
Constructors
-
Ramp(steps: List(Int), every: Int, next: option.Option(Int))
What is stored for a flag. rollout is in hundredths of a percent: 0 is
nobody, 2500 a quarter, 10000 everyone.
pub type Setting {
Setting(
killed: Bool,
rollout: Int,
bucketing: Bucketing,
allowed: List(Target),
blocked: List(Target),
ramp: option.Option(Ramp),
)
}
Constructors
-
Setting( killed: Bool, rollout: Int, bucketing: Bucketing, allowed: List(Target), blocked: List(Target), ramp: option.Option(Ramp), )
Everything a store keeps for the flags at one moment. version rises
with every change.
pub type Snapshot {
Snapshot(
version: Int,
settings: List(#(String, Setting)),
groups: List(GroupSummary),
)
}
Constructors
-
Snapshot( version: Int, settings: List(#(String, Setting)), groups: List(GroupSummary), )
Where the settings are kept: howdy/flags/database for a Gloo Repo,
howdy/flags/memory for one process, or your own.
pub opaque type Store
Who a rule or a group membership is about.
pub type Target {
User(String)
Organization(String)
Group(String)
}
Constructors
-
User(String) -
Organization(String) -
Group(String)
What a change to one flag does to what is stored for it.
pub type Update {
Unchanged
Save(setting: Setting, summary: String)
Delete(summary: String)
}
Constructors
-
UnchangedNothing changes and nothing is recorded.
-
Save(setting: Setting, summary: String)Store this setting, recorded with this summary.
-
Delete(summary: String)Delete what is stored, recorded with this summary.
How a store changes what it keeps. Every change is recorded as a
Change made by the name given (by) and raises the snapshot’s
version, together with the change itself. The arguments are validated
before a store sees them.
update(key, by, decide): in one atomic step, read what is stored forkey, pass it todecide, and store and record what it returns.Ok(True)when something changed. Two updates to a flag must not interleave; a store used by several nodes must serialize them itself.find_change(id): one recorded change, orNotFound.create_group(name, description, by):Conflictif it exists.delete_group(name, by):NotFoundif missing;Conflict, naming the flags, while a flag’s rule names the group.add_member(group, member, by),remove_member(group, member, by):NotFoundfor a missing group or member,Conflictfor a member already there.
pub type Writer {
Writer(
update: fn(
String,
String,
fn(option.Option(Setting)) -> Result(Update, service.Error),
) -> Result(Bool, service.Error),
find_change: fn(Int) -> Result(Change, service.Error),
create_group: fn(String, String, String) -> Result(
Nil,
service.Error,
),
delete_group: fn(String, String) -> Result(Nil, service.Error),
add_member: fn(String, Target, String) -> Result(
Nil,
service.Error,
),
remove_member: fn(String, Target, String) -> Result(
Nil,
service.Error,
),
)
}
Constructors
-
Writer( update: fn( String, String, fn(option.Option(Setting)) -> Result(Update, service.Error), ) -> Result(Bool, service.Error), find_change: fn(Int) -> Result(Change, service.Error), create_group: fn(String, String, String) -> Result( Nil, service.Error, ), delete_group: fn(String, String) -> Result(Nil, service.Error), add_member: fn(String, Target, String) -> Result( Nil, service.Error, ), remove_member: fn(String, Target, String) -> Result( Nil, service.Error, ), )
Values
pub fn add_member(
flags: Flags,
group: String,
member: Target,
by by: String,
) -> Result(Nil, service.Error)
Add a user or organization to a group.
pub fn allow(
flags: Flags,
key: String,
target: Target,
by by: String,
) -> Result(Nil, service.Error)
Turn the flag on for a target whatever the rollout, unless the kill switch is on or it is blocked. Replaces a block rule for the same target.
pub fn anonymous() -> Actor
Someone signed out. Only a flag’s default, its kill switch and a rollout of 100% apply to them.
pub fn block(
flags: Flags,
key: String,
target: Target,
by by: String,
) -> Result(Nil, service.Error)
Turn the flag off for a target whatever else applies. Replaces an allow rule for the same target.
pub fn bucketing_from_string(text: String) -> Bucketing
pub fn bucketing_to_string(bucketing: Bucketing) -> String
user or organization, as stores keep it.
pub fn cancel_ramp(
flags: Flags,
key: String,
by by: String,
) -> Result(Nil, service.Error)
Drop the ramp, leaving the rollout where it is.
pub fn check_every(
config: Config,
milliseconds milliseconds: Int,
) -> Config
How often to look for changes made elsewhere and take due ramp steps: the longest a kill switch can take to reach this node. 100 ms to a minute; the check asks the store for its version.
pub fn create_group(
flags: Flags,
name: String,
description description: String,
by by: String,
) -> Result(Nil, service.Error)
pub fn delete_group(
flags: Flags,
name: String,
by by: String,
) -> Result(Nil, service.Error)
Delete a group and its memberships. Refused while a flag’s rule names it, so a flag cannot change behind its history’s back.
pub fn description(flag: Flag) -> String
pub fn enabled(
flags: Flags,
flag: Flag,
for actor: Actor,
) -> Bool
Whether the flag is on for this actor. Never touches the store.
pub fn explain(
flags: Flags,
flag: Flag,
for actor: Actor,
) -> Decision
Whether the flag is on for this actor, and why, checked in this order: the kill switch, block rules, allow rules, then the rollout.
pub fn export(flags: List(Flag)) -> Nil
Print to_json on standard output, from a task module that lists the
app’s flags:
// src/tasks/flags.gleam, run with `gleam run -m tasks/flags`
pub fn main() -> Nil {
flags.export(my_app.flags())
}
Panics, so the command fails, when two flags share a key.
pub fn flag(key: String, description description: String) -> Flag
A flag that is off until someone turns it on. The key is 1 to 100 of
a-z, 0-9, _, . and -; it is how the flag is stored, so renaming
it starts the flag afresh.
pub fn forget(
flags: Flags,
key: String,
by by: String,
) -> Result(Nil, service.Error)
Delete what is stored for a flag, putting it back to its default. Works for keys that are no longer registered, to tidy them away.
pub fn groups(
flags: Flags,
) -> Result(List(GroupSummary), service.Error)
Every group, by name, with its members.
pub fn history(
flags: Flags,
of flag: option.Option(String),
limit limit: Int,
) -> Result(List(Change), service.Error)
The latest changes, newest first: every change, or only one flag’s.
pub fn in_organization(actor: Actor, id: String) -> Actor
The organization the actor is acting in, for groups and rules that name it and for rollouts by organization.
pub fn kill(
flags: Flags,
key: String,
by by: String,
) -> Result(Nil, service.Error)
Turn the flag off for everyone, whatever else it says, and pause any ramp. Its other settings are kept for when it is revived.
pub fn on_by_default(flag: Flag) -> Flag
A flag that is on until someone changes it, such as a finished feature kept behind a kill switch.
pub fn organization(id: String) -> Actor
An organization on its own, such as for a background job run for it.
pub fn parse_percent(text: String) -> Result(Int, Nil)
A percentage such as 12.5 or 5%, in hundredths of a percent.
pub fn pause_ramp(
flags: Flags,
key: String,
by by: String,
) -> Result(Nil, service.Error)
Stop a ramp where it is. Nobody gains or loses the flag until it is resumed or the rollout is changed.
pub fn percent(whole: Int) -> Int
A rollout from a whole percentage, such as percent(25).
pub fn percent_to_string(rollout: Int) -> String
A rollout as people read it, such as 25% or 0.5%.
pub fn refresh(flags: Flags) -> Result(Nil, service.Error)
Reload the settings now rather than at the next check, and wait for the
reload so a change made here is in force when this returns. The wait
is bounded: after five seconds this returns Error(service.Internal)
while the keeper carries on and publishes the settings when the store
answers, and the same error comes back at once when no keeper is
running. Neither crashes the caller.
pub fn register(config: Config, flags: List(Flag)) -> Config
The flags the app defines, so management can list them and refuse changes to keys that do not exist. Checks work for any flag.
pub fn remove_member(
flags: Flags,
group: String,
member: Target,
by by: String,
) -> Result(Nil, service.Error)
pub fn resume_ramp(
flags: Flags,
key: String,
by by: String,
) -> Result(Nil, service.Error)
Carry on with a paused ramp: its next step is every seconds from now.
pub fn revive(
flags: Flags,
key: String,
by by: String,
) -> Result(Nil, service.Error)
Turn the kill switch off again. A paused ramp stays paused.
pub fn set_bucketing(
flags: Flags,
key: String,
to bucketing: Bucketing,
by by: String,
) -> Result(Nil, service.Error)
Work out rollout positions by user or by organization. Changing it reshuffles who is in the rollout.
pub fn set_rollout(
flags: Flags,
key: String,
to rollout: Int,
by by: String,
) -> Result(Nil, service.Error)
Set the rollout, in hundredths of a percent (see percent). A ramp is
paused, so it does not undo a rollback at its next step.
pub fn setting(
flags: Flags,
key: String,
) -> Result(option.Option(Setting), service.Error)
What is stored for one flag, if anything.
pub fn setting_decoder() -> decode.Decoder(Setting)
pub fn setting_to_json(setting: Setting) -> json.Json
A setting as JSON, as snapshot_to_json writes each one.
pub fn settings(
flags: Flags,
) -> Result(List(#(String, Setting)), service.Error)
Every stored setting, by flag key, including keys no longer registered. Read from the store, not the in-memory copy.
pub fn snapshot(flags: Flags) -> Result(Snapshot, service.Error)
Everything the store keeps, read from it rather than the in-memory copy.
pub fn snapshot_decoder() -> decode.Decoder(Snapshot)
pub fn snapshot_to_json(snapshot: Snapshot) -> json.Json
A snapshot as JSON, the form a store can fetch from or hand to another
system. snapshot_decoder reads it back.
{"format": 1, "version": 12,
"settings": {"new_checkout": {"killed": false, "rollout": 500,
"bucketing": "user", "allowed": ["group:beta"], "blocked": [],
"ramp": {"steps": [100, 500, 10000], "every": 3600, "next": 1790000000}}},
"groups": [{"name": "beta", "description": "Beta testers",
"members": ["org:acme"]}]}
pub fn start(config: Config) -> Result(Flags, service.Error)
Load the settings and start keeping them current, in a process linked
to the caller. Prefer supervised in an app, so a keeper that crashes
is restarted rather than taking the caller with it.
pub fn start_ramp(
flags: Flags,
key: String,
steps steps: List(Int),
every every: Int,
by by: String,
) -> Result(Nil, service.Error)
Raise the rollout through steps (hundredths of a percent, rising),
taking the first step above the current rollout now and the next every
every seconds. Pause it, or roll back, at any point.
pub fn stop(flags: Flags) -> Nil
Stop keeping the settings current and let go of them. Checks on these flags answer with the flags’ defaults afterwards. Stopping flags whose keeper is not running does nothing.
pub fn store(
named name: String,
load load: fn() -> Result(Snapshot, service.Error),
version version: fn() -> Result(Int, service.Error),
history history: fn(option.Option(String), Int) -> Result(
List(Change),
service.Error,
),
) -> Store
A read-only store, such as one fed by a service that manages the flags
elsewhere. load returns everything; version is cheap, and is called
every check interval to decide whether to load again; history(flag, limit) returns the latest changes, newest first, or [] if it keeps
none. name says where the flags are kept, in messages.
pub fn store_name(flags: Flags) -> String
Where the flags are kept, such as database.
pub fn supervised(
config: Config,
) -> #(Flags, supervision.ChildSpecification(Flags))
The flags as a child of a supervisor, with the handle to check them by. The handle is made here, before the keeper starts, so it can be given to the app while the supervisor owns the keeper.
let #(features, keeper) = flags.supervised(config)
static_supervisor.new(static_supervisor.OneForOne)
|> static_supervisor.add(keeper)
|> static_supervisor.start
The keeper loads the settings when it starts, so a restart after a crash
replaces whatever it had published with fresh settings, and every check
keeps working meanwhile from the last ones. It is restarted after a
crash and not after stop, which lets go of the settings for good.
While the supervisor’s shutdown takes the keeper down, it lets go of
them too, so checks answer with the flags’ defaults rather than settings
nobody keeps current.
pub fn target_from_string(text: String) -> Result(Target, Nil)
Parse what target_to_string produced.
pub fn target_to_string(target: Target) -> String
How a target is stored and shown: user:<id>, org:<id> or
group:<name>.
pub fn to_json(flags: List(Flag)) -> json.Json
The flags as JSON, for tools outside the app, such as one that prepares a database before the flags are turned on. Nothing is read from a database: this is what the code defines.
{"format": 1, "flags": [
{"key": "new_checkout", "description": "Stripe-hosted checkout", "default": false}
]}
format changes only if a field changes meaning or goes away; new
fields may be added.
pub fn undo(
flags: Flags,
change id: Int,
by by: String,
) -> Result(Nil, service.Error)
Take the flag back to how it was before a change: the change’s before,
with any ramp paused so it does not step straight away. Recorded as a
change of its own.
pub fn unlist(
flags: Flags,
key: String,
target: Target,
by by: String,
) -> Result(Nil, service.Error)
Remove the allow or block rule for a target.