Feature flags
howdy_flags is an optional package for feature flags: switches defined
in code and changed while the app runs. A flag can be turned off for
everyone at once, allowed or blocked for particular users, organizations
and groups, and rolled out gradually to a share of
users. Where the settings are kept is up to a store:
the app's database, one process's memory, or a service that manages them
for you.
howdy_flags = { git = "https://github.com/mikeyjones/howdy.git", ref = "v2", path = "flags" }
howdy_flags is not in the v2.0.0 release, so depend on the v2 branch
until the next tag. In a project made with howdy new, howdy add flags
adds the package, a module for the app's flags with an example, a task
module for the command line, and a test. Flags
keep their settings in the app's database, so the project needs
howdy_database.
Defining flags
A flag is a key, a description and a default. Its key is 1 to 100 of
a-z, 0-9, _, . and -, and is how the flag is stored, so renaming
it starts the flag afresh. With nothing stored for it, its default
applies, so a flag works in tests, on a fresh deployment, and before anyone
has touched it.
import howdy/flags
pub fn new_checkout() -> flags.Flag {
flags.flag("new_checkout", description: "Stripe-hosted checkout")
}
// A finished feature kept behind a kill switch.
pub fn search_v2() -> flags.Flag {
flags.flag("search_v2", description: "New search") |> flags.on_by_default
}
pub fn all_flags() -> List(flags.Flag) {
[new_checkout(), search_v2()]
}
Keep every flag in one list, so startup, the admin and the command line agree on what exists. Management refuses changes to keys that are not registered, though checks work for any flag.
Starting
Run the database store's schema with the app's other migrations, open a store on the database, then start the flags once at startup and hand them to the app through its services:
import howdy/flags
import howdy/flags/database as flags_database
import howdy/migration
let assert Ok(Nil) = migration.run(db, [flags_database.schema()])
let assert Ok(store) = flags_database.store(db)
let assert Ok(features) =
flags.new(store)
|> flags.register(all_flags())
|> flags.start
flags.start loads the settings and starts a process, the keeper, that
keeps them current, linked to the caller. In an app, prefer
flags.supervised(config), which returns the handle and a child
specification to put under a supervisor: the
handle keeps working across restarts of the keeper. The store refuses to
start on a database whose schema has not been migrated.
For tests, or an app on one node without a database, howdy/flags/memory
keeps the settings in a process instead:
import howdy/flags/memory
let assert Ok(features) =
flags.new(memory.new()) |> flags.register(all_flags()) |> flags.start
Checking a flag
flags.enabled says whether a flag is on for someone. Checks read an
in-memory copy of the settings and never query the store, so they are cheap
enough to call on every request:
case flags.enabled(features, new_checkout(), for: flags.user(user.id)) {
True -> new_checkout_page(ctx)
False -> checkout_page(ctx)
}
Who the check is for is an actor: flags.user(id),
flags.organization(id), both with flags.user(id) |>
flags.in_organization(org), or flags.anonymous(). The ids are yours,
such as a Howdy Auth user id: the package does not
depend on howdy_auth.
flags.explain returns a Decision saying why the flag is on or off, for
support and debugging, and flags.is_on turns it back into a boolean.
What decides a check
In this order, the first that applies wins:
-
Nothing stored for the flag: its default,
Default(on:). -
The kill switch is on: off,
Killed. -
A block rule names the user, their organization, or a group either is
in: off,
Blocked(target). -
An allow rule names one of them: on,
Allowed(target). -
Their rollout position is below the rollout: on,
InRollout. Otherwise off,OutsideRollout.
Someone without the id the rollout is by, such as an anonymous user in a
rollout by user, gets NoPosition and only sees the flag at 100%.
Seeing changes
A change made on a node applies there before the call that made it
returns. Other nodes ask the store for its version every two seconds and
reload when it has moved, so that is the longest a kill switch takes to
reach every node. flags.check_every(config, milliseconds:) sets the
interval, from 100 ms to a minute, and flags.refresh(features) reloads
now and waits for it. If the store becomes unreachable, checks keep the
last settings they loaded.
In development the admin area shows the flags under
Flags, with their kill switches, rules, groups and history, once they are
registered with admin.flags(features).
