navree
Documentation

Feature flags

Define flags as types with #[flag], turn them on per user, per workspace, by plan or by percentage, check them with Flags or #[requires_flag], and store them in your database with a cache in front.

On this page

Introduction

Featureflags A feature flag separates shipping code from releasing it. You merge the new deal pipeline behind a flag, turn it on for your own workspace, then for 10% of customers, then for everyone, and turn it off in seconds if something goes wrong, without a deploy.

navree-flags keeps flags type-safe and close to your code:

  • A flag is a Rust type, defined with #[flag]. A typo in a flag name is a compile error, and flags:purge finds flags that no longer exist in code.
  • You check a flag with the Flags service, the CurrentFlags extractor, or #[requires_flag] on a route.
  • A flag is resolved for a scope: a user, a workspace, or both. Turn it on for one user or one workspace, for a percentage of either, for workspaces on given plans, or for everyone.
  • Flag state lives in two tables in your database and is cached, so a check during a request costs nothing after the first one.

Installation

The flags feature of the navree facade is opt-in (it is not part of full), so turn it on by name:

Cargo.toml

It brings navree::flags (the navree-flags crate plus the #[flag] macro) and puts FlagsModule and CurrentFlags in the prelude.

Add FlagsModule to your app root. It needs the database; with CacheModule in the app it caches flag state there, with AuditModule every change is recorded, and with EventsModule every change emits an event:

src/app.rs

The module is global and contributes the migration set flags, which creates feature_flags and feature_flag_overrides. Its admin routes declare a permission, so the app also needs AccessModule (or set flags.admin_routes = false); without either, the boot fails with a message that says so.

Configuration

Every key has a default:

config/app.toml

From the environment: APP_FLAGS__STORE=memory, APP_FLAGS__CACHE_TTL_SECS=10. FlagsModule::for_root(FlagsOptions { .. }) takes the same options in code.

Defining flags

A flag is a unit struct with #[flag]. Its doc comment becomes its description in flags:list and the admin routes:

src/modules/deals/flags.rs
ArgumentDefaultMeaning
namethe type name in kebab case (new-pipeline)The stored name. Keep it stable: state is keyed by it.
defaultfalseThe value when nothing else decides
exposeoffIncludes the flag in GET /api/flags, for the front end

A name holds lower-case letters, digits, -, _ and . (at most 100 characters, starting with a letter or a digit). A struct with fields or generics, an unknown argument or an invalid name is a compile error. #[flag] implements the Flag trait, whose constants (NAME, DEFAULT, EXPOSE, DESCRIPTION) you can also write by hand.

Register each flag with FlagsModule::for_feature, in the module that owns it:

src/modules/deals/mod.rs

In a register function, m.flag::<NewPipeline>() does the same (ModuleBuilderFlagsExt). Two registered flags with the same name fail the boot, naming both types. Checking or changing a flag that no module registered fails too, with FlagsError::Unregistered, so a forgotten registration shows up in the first test that touches it.

Checking flags

In handlers: CurrentFlags

CurrentFlags resolves flags for the request's signed-in user and, when the app has workspaces and the request names one the user belongs to, the request's CurrentWorkspace. Results are memoized for the request:

src/modules/deals/controller.rs

A request that names no workspace, an unknown one, or one the user is not a member of resolves for the user alone. A guest's flags are resolved for neither (see missing_scope). flags.all() returns every registered flag's value and flags.scope() who they were resolved for.

Anywhere: the Flags service

Inject Arc<Flags> and pass a scope. Anything that converts into a FlagScope works: a &User, a &Workspace, a &CurrentWorkspace (its member in that workspace), a &Principal, or a scope you build:

Rust

FlagScope::user(id), FlagScope::workspace(id) and FlagScope::user(id).workspace(id) name who is asking; FlagScope::everyone() names nobody. FlagScope::user(id) returns a UserScope, which converts into a FlagScope (and narrows to a workspace with .workspace(id)). flags.active::<NewPipeline>(&user) and flags.active::<NewPipeline>(&workspace) work too.

On routes: #[requires_flag]

A route that only exists behind a flag declares it. #[controller] consumes the attribute, like #[can], so there is nothing to import:

Rust

When the flag is off for the request's scope (as CurrentFlags resolves it), the route answers 404 Not Found, as if it didn't exist, before its authorization (#[can], #[roles], #[permissions]), its other guards, its extractors and its handler run. To answer 403 instead, write #[requires_flag(NewPipeline, status = 403)]; no other status is accepted. Access is still declared with #[can], #[authenticated] or #[public]: a flag is not an access rule, and a guest on an authenticated route still gets 401. The OpenAPI document lists the route with an x-navree-flag: new-pipeline extension.

#[requires_flag] goes on route methods, one per route. On a hand-written RouteDef, navree::flags::requires_flag::<NewPipeline>(route, 404) adds the same guard and extension, and RequireFlag::guard::<NewPipeline>() is the guard alone.

Scopes and rollouts

How a flag is resolved

For a scope with a user U and a workspace W, the first rule that decides wins:

  1. Killed. A killed flag is off for everyone.
  2. User override. U was turned on or off for this flag.
  3. Workspace override. W was turned on or off.
  4. Everyone. The flag was turned on or off for everyone.
  5. Rollout. The flag has a rollout and U (or W) falls inside it.
  6. Default. The default from #[flag].

A check that names only a user, or only a workspace, uses that one's override and skips the other; a rollout that counts what the scope lacks (a workspace rollout for a user-only scope) does not include it. A check with neither, from a job or a command, can only be decided by the kill switch, the global state and the default. With flags.missing_scope = "off", such a check is off unless the flag is on for everyone.

Turning flags on and off

Rust

An override is for one user or one workspace: a scope with both is FlagsError::InvalidScope. With FlagScope::everyone(), activate, deactivate and forget set or unset the global state.

Writes join the current unit of work when there is one (a request's while its handler runs), so a flag change made in a request commits or rolls back with it; otherwise they run in a unit of work of their own. Each change clears the cached state it touches, and records the change:

  • with EventsModule, it emits FlagChanged (flag.changed) once the unit of work commits: the flag, the change (activated, deactivated, forgotten, rollout_set, rollout_stopped, killed, revived or purged), the user or workspace of an override, the old and new values and the user who made it;
  • with AuditModule, it writes a flag.changed audit entry (subject Flag, the flag's name) with the old and new values and the user who made it, in the same transaction.

A change that changes nothing (forgetting an override that does not exist) records nothing. The memory store has no transactions: its changes apply at once.

Percentage rollouts

A rollout turns a flag on for a share of users or workspaces:

Rust

Each workspace (or user) falls in a bucket from 0 to 99: the first eight bytes of the SHA-256 of <flag name>:<id>, read as a big-endian number, modulo 100. It is inside the rollout when its bucket is below the percentage. The bucket never changes, so raising the percentage only adds workspaces: everyone who had the feature at 10% keeps it at 50%. Different flags hash differently, so the same customers aren't the first to get every feature.

Roll out by workspace when a feature changes shared data (a pipeline board everyone in the workspace sees), and by user (Rollout::percent(10), the default) when it only changes one person's view. flags.stop_rollout::<NewPipeline>() removes the rollout; a percentage above 100 is FlagsError::InvalidRollout.

Rolling out by plan

A rollout can be limited to workspaces on some plans:

Rust

The plan comes from the app's dyn PlanResolver, which a module registers with m.plan_resolver::<R>() (ModuleBuilderFlagsExt). A workspace the resolver gives no plan (None) is outside the rollout. With billing, read the plan from the workspace's local subscription copy, so a check costs no call to the payment provider:

src/modules/billing/plans.rs

Setting a rollout by plan in an app without a resolver is FlagsError::NoPlanResolver. The plan is cached like the rest of the state, so a workspace that leaves the plan loses the feature on the next check after the cache expires.

Kill switch

kill turns a flag off for everyone, overrides included, in one write. Use it when the feature is causing an incident:

Rust

The same from a shell, with the app's configuration:

Shell

In the front end

Flags marked expose are served to the signed-in user at GET /api/flags, resolved for the request's user and workspace:

HTTP
JSON

Flags without expose never leave the server, so you can name an unreleased feature without announcing it. The response carries Cache-Control: private, max-age=60 (flags.cache_ttl_secs). Guests get 401.

Managing flags

From the command line

CommandWhat it does
flags:listEvery registered flag with its default, global state (on, off, killed or -), rollout and override count
flags:on <flag> [--user <id>] [--workspace <id-or-slug>]Activates for one user or workspace; without an option, for everyone
flags:off <flag> [--user <id>] [--workspace <id-or-slug>]Deactivates the same way
flags:forget <flag> [--user <id>] [--workspace <id-or-slug>]Removes an override, or resets the global state to unset
flags:rollout <flag> <percent> [--of users|workspaces] [--plans pro,business]Sets a rollout; flags:rollout <flag> off removes it
flags:kill <flag>, flags:revive <flag>The kill switch
flags:purge [--dry-run]Deletes the state of flags no module registers any more
Shell
Text

A workspace slug needs WorkspaceModule in the app; an id always works. An unknown flag or workspace is an error, and so is a --user that is not a UUID (user ids are not looked up, so you can turn a flag on for an account before it exists).

From your admin screens

With flags.admin_routes = true, the module mounts JSON routes under /api/admin/flags that require the flags.manage permission:

RouteAnswers
GET /api/admin/flags{"data": [..]}: every registered flag with its description, default, expose, everyone, rollout, killed, override count, updated_at and updated_by
GET /api/admin/flags/{name}{"flag": {..}, "overrides": [{flag, scope_type, scope_id, active}, ..]}
PUT /api/admin/flags/{name}Sets the fields present in {"everyone": true | false | null, "rollout": {"percent", "of", "plans"} | null, "killed": bool}; answers like GET /{name}
PUT /api/admin/flags/{name}/overrides{"user_id" or "workspace_id", "active"}; answers like GET /{name}
DELETE /api/admin/flags/{name}/overrides?user_id=.. (or workspace_id)204

An unknown flag is a 404, an invalid rollout or an override naming both or neither of a user and a workspace a 400.

Caching

With CacheModule in the app, the global state of every flag, the overrides of each user and workspace, and each workspace's plan are cached for flags.cache_ttl_secs (60 seconds), and CurrentFlags memoizes results for the length of a request. A change made through Flags clears the cached state it touches at once, and again when its unit of work commits. Checks inside a unit of work whose transaction is open read through that transaction, so they see its uncommitted changes, and are not cached. Without CacheModule, or with cache_ttl_secs = 0, every check reads the database.

With several app instances, where the change shows up depends on the cache store. With the memory store (the default) each instance has its own cache, so a change made on one instance reaches the others when their cached entry expires: within cache_ttl_secs. Lower it if you need faster propagation; a check that misses the cache costs a few indexed queries. With the Redis store (cache.store = "redis") every instance shares the cache, so a change clears the shared entries and every instance sees it on its next check.

Testing

Use the memory store and set the flags each test needs:

tests/pipeline.rs

To force a value without storage, install a FlagsFake. It answers what you tell it, whatever the scope, and records every check:

Rust

Flags the fake wasn't told about resolve to their default. fake.set::<F>(value) changes an answer while the app runs, fake.checked::<F>() counts the checks and fake.checks_of::<F>() lists their scopes. Writes through the fake's Flags change its answers (activate turns a flag on, deactivate and kill turn it off, forget and revive return it to its default); rollouts are ignored. A test for a percentage rollout can compute who is inside it with Rollout::percent(25).of_workspaces().includes::<NewPipeline>(workspace_id).

Reference

KeyDefaultMeaning
flags.storedatabasedatabase or memory
flags.cache_ttl_secs60Cache lifetime of resolved state; 0 turns the cache off
flags.routestrueMounts GET /api/flags
flags.admin_routestrueMounts /api/admin/flags (permission flags.manage)
flags.missing_scopedefaultdefault or off, for checks without a user or workspace
Flags method
active::<F>(scope)Whether F is on for the scope
all(scope)Every registered flag's value for the scope, by name
activate::<F>(scope), deactivate::<F>(scope)Sets an override, or the global state with FlagScope::everyone()
forget::<F>(scope)Removes the override (or the global state)
rollout::<F>(rollout), stop_rollout::<F>()Sets or removes the rollout
kill::<F>(), revive::<F>()The kill switch
state::<F>()The stored state (FlagState), for admin screens
registered()Every registered flag's definition
TableHolds
feature_flagsOne row per flag that has state: name, everyone (on, off or unset), rollout (JSON), killed, updated_at, updated_by
feature_flag_overridesflag, scope_type (user or workspace), scope_id, active; keyed by all three but active, indexed by scope

Storage sits behind the FlagStore trait: DatabaseFlagStore (the tables above) and MemoryFlagStore (store = "memory"). The crate's default auth feature gives the conversions from navree-auth's User and CurrentUser.