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:

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

Flags being configured, before start.

pub opaque type Config

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.

  • Killed

    The 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 named set of users and organizations that rules can target.

pub type GroupSummary {
  GroupSummary(
    name: String,
    description: String,
    members: List(Target),
  )
}

Constructors

  • GroupSummary(
      name: String,
      description: String,
      members: List(Target),
    )

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

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

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

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

  • Unchanged

    Nothing 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 for key, pass it to decide, 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, or NotFound.
  • create_group(name, description, by): Conflict if it exists.
  • delete_group(name, by): NotFound if missing; Conflict, naming the flags, while a flag’s rule names the group.
  • add_member(group, member, by), remove_member(group, member, by): NotFound for a missing group or member, Conflict for 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

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 default(flag: Flag) -> Bool
pub fn defined(flags: Flags) -> List(Flag)

The flags registered with register, in order.

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 is_on(decision: Decision) -> Bool
pub fn key(flag: Flag) -> String
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 new(store: Store) -> Config

Flags kept in this store.

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.

pub fn user(id: String) -> Actor
pub fn with_writer(store: Store, writer: Writer) -> Store

A store that can also be changed through this package: by the management functions, the admin, and howdy/flags/cli.

pub fn writable(flags: Flags) -> Bool

Whether the flags can be changed from here. Read-only flags are managed wherever their store gets them from.

✨ Search Document