Documentation
¶
Overview ¶
Package featureflags provides feature toggles and gradual rollouts for SaaS applications, evaluated in memory so that a request never waits on a database.
Model ¶
A Flag has a set of named Variants (a boolean flag is the two-variant case VariantOn / VariantOff), a DefaultVariant and an Enabled kill switch. Evaluating an enabled flag for an EvalContext (user ID, tenant ID and attributes) checks, in order:
- Targets: explicit user or tenant IDs (ReasonTargetMatch).
- Rules: attribute Conditions (eq, neq, in, not_in, contains, lt, lte, gt, gte), serving a fixed variant or a weighted Distribution (ReasonRuleMatch, with the rule's index).
- Rollout: a flag-level Distribution for everyone else (ReasonRollout).
- DefaultVariant (ReasonDefault).
A disabled flag serves DefaultVariant with ReasonDisabled, and a key the snapshot does not contain yields ReasonFlagNotFound with the zero Variant; Client.Enabled and Client.Variant then return the default the caller passed.
Conditions compare strings, booleans and numbers exactly and case-sensitively; values of different kinds are never equal, and a missing or unusable attribute never satisfies a condition, including neq and not_in. See Operator for the full semantics.
Distributions use deterministic, sticky bucketing: see Bucket for the documented hash, which is stable across processes, platforms and releases. The flag key is part of the hash, so every flag samples a different set of users.
Evaluation ¶
A Client holds an immutable Snapshot behind an atomic pointer. Client.Run refreshes it by polling the Source and, when a Notifier is configured, on pushed change signals (pgxstore.Listener uses PostgreSQL LISTEN/NOTIFY and reconnects on its own). Evaluation reads only the snapshot, never blocks, performs no I/O and never panics on a definition that passed Validate. A failing refresh keeps the last good snapshot, and a definition that fails validation keeps the previous version of that one flag while the others refresh normally.
Management ¶
A Manager validates definitions, writes them to a Store with optimistic concurrency (Flag.Version; a stale write fails with ErrVersionConflict, so concurrent updates never silently overwrite each other) and emits ChangeEvents naming the actor as a Ref for audit. The memstore and pgxstore subpackages implement Store; storetest is the conformance suite for other implementations; ParseJSON and FileSource support config-as-code; and featureflagstest gives application tests a Client they control.
Context values ¶
The EvalContext and the actor travel in the context through WithEvalContext and WithActor. An application that already keeps its user in the context can supply ClientOptions.EvalContextFrom and ManagerOptions.ActorFrom instead, so it states who is acting once for all packages.
Errors ¶
Errors wrap ErrNotFound, ErrInvalid or ErrConflict, through the package-specific errors ErrFlagNotFound, ErrInvalidFlag, ErrFlagExists and ErrVersionConflict, so check them with errors.Is.
Example ¶
package main
import (
"context"
"fmt"
featureflags "github.com/byteminus/go-featureflags"
"github.com/byteminus/go-featureflags/memstore"
)
func main() {
ctx := context.Background()
store := memstore.New()
manager, err := featureflags.NewManager(store, featureflags.ManagerOptions{})
if err != nil {
panic(err)
}
flag := featureflags.NewBoolFlag("new-checkout", "Redesigned checkout flow")
flag.Targets = []featureflags.Target{{Variant: featureflags.VariantOn, Users: []string{"qa-lead"}}}
flag.Rules = []featureflags.Rule{{
Conditions: []featureflags.Condition{{Attribute: "plan", Op: featureflags.OpEquals, Value: "enterprise"}},
Variant: featureflags.VariantOn,
}}
if _, err := manager.Create(ctx, flag); err != nil {
panic(err)
}
client, err := featureflags.NewClient(store, featureflags.ClientOptions{})
if err != nil {
panic(err)
}
if err := client.Refresh(ctx); err != nil {
panic(err)
}
for _, ec := range []featureflags.EvalContext{
{UserID: "qa-lead"},
{UserID: "u-17", Attributes: map[string]any{"plan": "enterprise"}},
{UserID: "u-18", Attributes: map[string]any{"plan": "free"}},
} {
reqCtx := featureflags.WithEvalContext(ctx, ec)
r := client.Evaluate(reqCtx, "new-checkout")
fmt.Println(ec.UserID, client.Enabled(reqCtx, "new-checkout", false), r.Reason, r.Index)
}
// A flag the source does not define returns the caller's default.
fmt.Println(client.Enabled(ctx, "no-such-flag", false), client.Enabled(ctx, "no-such-flag", true))
}
Output: qa-lead true target_match 0 u-17 true rule_match 0 u-18 false default -1 false true
Index ¶
- Constants
- Variables
- func Bucket(flagKey, salt, bucketingKey string) int
- func Validate(f Flag) error
- func ValidateKey(key string) error
- func WithActor(ctx context.Context, actor Ref) context.Context
- func WithEvalContext(ctx context.Context, ec EvalContext) context.Context
- type BucketBy
- type ChangeEvent
- type ChangeType
- type Client
- func (c *Client) Enabled(ctx context.Context, key string, def bool) bool
- func (c *Client) Evaluate(ctx context.Context, key string) Result
- func (c *Client) LastRefresh() time.Time
- func (c *Client) Refresh(ctx context.Context) error
- func (c *Client) Run(ctx context.Context) error
- func (c *Client) Snapshot() *Snapshot
- func (c *Client) Variant(ctx context.Context, key string, def Variant) Variant
- type ClientOptions
- type Condition
- type Distribution
- type EvalContext
- type EvaluationEvent
- type Flag
- type Manager
- func (m *Manager) Create(ctx context.Context, f Flag) (Flag, error)
- func (m *Manager) Delete(ctx context.Context, key string, version int64) error
- func (m *Manager) Get(ctx context.Context, key string) (Flag, error)
- func (m *Manager) List(ctx context.Context) ([]Flag, error)
- func (m *Manager) Update(ctx context.Context, f Flag) (Flag, error)
- type ManagerOptions
- type Notifier
- type Operator
- type Reason
- type Ref
- type Result
- type Rule
- type Snapshot
- type Source
- type SourceFunc
- type Store
- type Target
- type ValidationError
- type Variant
- type Weight
Examples ¶
Constants ¶
const ( DefaultPollInterval = 30 * time.Second DefaultRefreshTimeout = 10 * time.Second )
Defaults applied by NewClient for zero ClientOptions fields.
const ( VariantOn = "on" VariantOff = "off" )
Variant names used by boolean flags. Client.Enabled reports true exactly when a flag resolves to the variant named VariantOn.
const TotalWeight = 10000
TotalWeight is the sum that the weights of a Distribution must reach. Weights are expressed in basis points, so 1 is 0.01% of bucketing keys.
Variables ¶
var ( // ErrNotFound reports that the requested record does not exist. ErrNotFound = errors.New("featureflags: not found") // ErrInvalid reports input that failed validation. ErrInvalid = errors.New("featureflags: invalid input") // ErrConflict reports a uniqueness violation, or a record whose state no // longer allows the operation. ErrConflict = errors.New("featureflags: conflict") )
Family sentinels. Every error this module returns for a missing record, rejected input or a conflicting state wraps exactly one of them, so an application can map all Byte Minus packages to HTTP statuses the same way with errors.Is.
var ( // ErrFlagNotFound reports that no flag exists with the requested key. It // wraps ErrNotFound. ErrFlagNotFound = fmt.Errorf("featureflags: flag not found: %w", ErrNotFound) // ErrFlagExists reports a Create for a key that is already taken. It // wraps ErrConflict. ErrFlagExists = fmt.Errorf("featureflags: flag already exists: %w", ErrConflict) // ErrVersionConflict reports a write whose expected version is not the // stored version: someone else changed the flag since it was read. It // wraps ErrConflict. ErrVersionConflict = fmt.Errorf("featureflags: version conflict: %w", ErrConflict) // ErrInvalidFlag reports a definition that fails validation. The // concrete error is a *ValidationError when it comes from Validate. It // wraps ErrInvalid. ErrInvalidFlag = fmt.Errorf("featureflags: invalid flag: %w", ErrInvalid) // ErrNoPayload reports Variant.Decode on a variant without a payload. It // wraps ErrNotFound. ErrNoPayload = fmt.Errorf("featureflags: variant has no payload: %w", ErrNotFound) // ErrAlreadyRunning reports a second concurrent call to Client.Run. It // wraps ErrConflict. ErrAlreadyRunning = fmt.Errorf("featureflags: client is already running: %w", ErrConflict) )
Package-specific errors. Each wraps one family sentinel; stores and the Manager wrap them in turn, so check with errors.Is.
Functions ¶
func Bucket ¶
Bucket returns the bucket in [0, TotalWeight) that bucketingKey falls into for the given flag key and salt.
The algorithm is part of the package's compatibility promise, because changing it would move users between variants of running rollouts:
input = flagKey + 0x00 + salt + 0x00 + bucketingKey (bytes as given) digest = SHA-256(input) bucket = uint64(big-endian digest[0:8]) mod 10000
The strings are hashed as their raw bytes (UTF-8 for Go strings), without trimming, case folding or Unicode normalization. The bucketing key is the user ID or tenant ID selected by Distribution.BucketBy. A Distribution serves the i-th weighted variant to buckets in [sum of weights before i, sum of weights up to i), so weight 0 serves nobody and weight TotalWeight serves everybody.
Flag keys and salts cannot contain NUL bytes, so the input is unambiguous. SHA-256 is used for its uniformity and its availability in every language, which lets other services reproduce assignments; it is not a security boundary. The modulo bias of 2^64 mod 10000 is below 1e-15 and irrelevant.
Example ¶
package main
import (
"fmt"
featureflags "github.com/byteminus/go-featureflags"
)
func main() {
fmt.Println(featureflags.Bucket("new-checkout", "", "user-1"))
fmt.Println(featureflags.Bucket("new-checkout", "", "user-2"))
}
Output: 1479 8982
func Validate ¶
Validate checks a flag definition: a well-formed key and variant names, unique variants, valid JSON payloads, references to existing variants only, well-formed conditions, distributions whose weights sum to TotalWeight, and the size limits documented on Flag. The error is a *ValidationError listing every problem; it wraps ErrInvalidFlag and ErrInvalid. A definition that passes Validate can be evaluated for any EvalContext without panicking.
Example ¶
package main
import (
"fmt"
featureflags "github.com/byteminus/go-featureflags"
)
func main() {
flag := featureflags.NewBoolFlag("checkout", "")
flag.Rollout = &featureflags.Distribution{Weights: []featureflags.Weight{{Variant: "on", Weight: 4000}}}
fmt.Println(featureflags.Validate(flag))
}
Output: featureflags: invalid flag "checkout": rollout: weights sum to 4000, want 10000
func ValidateKey ¶
ValidateKey reports whether key is a well-formed flag key: 1 to 128 characters from a-z, 0-9, '.', '_' and '-', starting with a letter or digit. The restriction keeps keys safe in URLs, logs and the bucketing hash input. The error wraps ErrInvalidFlag.
func WithActor ¶
WithActor returns a copy of ctx carrying the party that performs management writes; the Manager records it in ChangeEvent.Actor for audit.
func WithEvalContext ¶
func WithEvalContext(ctx context.Context, ec EvalContext) context.Context
WithEvalContext returns a copy of ctx carrying ec, typically set once per request by middleware after authentication.
Types ¶
type BucketBy ¶
type BucketBy string
BucketBy selects which identifier of the EvalContext feeds the bucketing hash of a Distribution.
type ChangeEvent ¶
type ChangeEvent struct {
Type ChangeType
Key string
// Actor is the party that performed the write, from
// ManagerOptions.ActorFrom; the zero Ref when the context names nobody.
Actor Ref
// Before is nil for ChangeCreated; After is nil for ChangeDeleted.
Before *Flag
After *Flag
// At is the UTC time the Manager observed the write.
At time.Time
}
ChangeEvent describes one successful management write, for audit logs.
type ChangeType ¶
type ChangeType string
ChangeType names the kind of write in a ChangeEvent.
const ( ChangeCreated ChangeType = "created" ChangeUpdated ChangeType = "updated" ChangeDeleted ChangeType = "deleted" )
Change types.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client evaluates flags against an in-memory Snapshot that is replaced atomically on every refresh, so evaluation never waits on the source.
Safe defaults: until the first successful refresh, and for any key the source does not define, Evaluate reports ReasonFlagNotFound while Enabled and Variant return the default the caller passed. A failing source leaves the last good snapshot in place. A Client is safe for concurrent use.
func NewClient ¶ added in v0.2.0
func NewClient(source Source, opts ClientOptions) (*Client, error)
NewClient returns a Client reading from source. It performs no I/O: call Refresh to load synchronously (for example to fail fast at startup) and Run to keep the snapshot current. A nil source is an error wrapping ErrInvalid.
func (*Client) Enabled ¶
Enabled reports whether key resolves to VariantOn for the EvalContext of ctx. When the snapshot holds no flag with that key (it was never created, was deleted, or no refresh has succeeded yet) it returns def instead, so the caller decides what a missing flag means. A flag that exists but is disabled resolves to its DefaultVariant, not to def.
func (*Client) Evaluate ¶
Evaluate resolves key for the EvalContext of ctx (see ClientOptions.EvalContextFrom) and reports the variant together with the reason it was chosen. A key the snapshot does not hold yields ReasonFlagNotFound and the zero Variant.
func (*Client) LastRefresh ¶
LastRefresh returns when the snapshot was last replaced, in UTC, or the zero time if no refresh has succeeded.
func (*Client) Refresh ¶
Refresh loads all flags from the source and atomically replaces the snapshot. If the source fails, the current snapshot is kept and the error is returned (and passed to OnError).
A definition that fails validation does not break the refresh: the previous version of that flag is kept if there was one, otherwise the flag is left out, and the problem is passed to OnError. An invalid row therefore never turns a working flag off and never holds back changes to the other flags. When the source lists a key twice, the first definition wins and the duplicate is reported.
func (*Client) Run ¶
Run keeps the snapshot current until ctx is done: it refreshes at once, then on every poll tick and on every Notifier signal, coalescing signals that arrive during a refresh. Each refresh is bounded by RefreshTimeout. Refresh errors go to OnError and never stop the loop. Run returns nil after ctx is done and its Notifier has returned, so no goroutine started by Run outlives it. A second Run while one is active returns an error wrapping ErrAlreadyRunning; Run may be called again after it returned.
type ClientOptions ¶ added in v0.2.0
type ClientOptions struct {
// PollInterval is how often Run reloads from the source. Zero means
// DefaultPollInterval; a negative value disables polling, leaving
// refreshes to the Notifier and explicit Refresh calls.
PollInterval time.Duration
// RefreshTimeout bounds each refresh performed by Run, so a hung source
// cannot stall later refreshes. Zero or negative means
// DefaultRefreshTimeout.
RefreshTimeout time.Duration
// Notifier, when set, triggers refreshes from pushed change signals
// while Run is active.
Notifier Notifier
// EvalContextFrom returns the EvalContext an evaluation runs for. Nil
// means the package-level EvalContextFrom, which reads the value stored
// by WithEvalContext. When it reports false the zero EvalContext is
// used.
EvalContextFrom func(ctx context.Context) (EvalContext, bool)
// OnEvaluation runs synchronously on the request path after every
// evaluation through the Client. It must be fast and must not block;
// hand events to a buffered channel for anything slow.
OnEvaluation func(ctx context.Context, e EvaluationEvent)
// OnError receives refresh failures and rejected flag definitions. It
// must not block. Errors are discarded when it is nil.
OnError func(err error)
// Now is the clock used for LastRefresh; time.Now when nil.
Now func() time.Time
}
ClientOptions configures a Client. The zero value is ready to use.
Example ¶
ExampleClientOptions wires the context lookups to an application's own context values, so the user is stated once for every package.
package main
import (
"context"
"fmt"
featureflags "github.com/byteminus/go-featureflags"
)
func main() {
type userKey struct{}
type user struct{ ID, Org, Plan string }
source := featureflags.SourceFunc(func(context.Context) ([]featureflags.Flag, error) {
f := featureflags.NewBoolFlag("pro-reports", "")
f.Rules = []featureflags.Rule{{
Conditions: []featureflags.Condition{{Attribute: "plan", Op: featureflags.OpEquals, Value: "pro"}},
Variant: featureflags.VariantOn,
}}
return []featureflags.Flag{f}, nil
})
client, err := featureflags.NewClient(source, featureflags.ClientOptions{
EvalContextFrom: func(ctx context.Context) (featureflags.EvalContext, bool) {
u, ok := ctx.Value(userKey{}).(user)
return featureflags.EvalContext{
UserID: u.ID,
TenantID: u.Org,
Attributes: map[string]any{"plan": u.Plan},
}, ok
},
})
if err != nil {
panic(err)
}
ctx := context.Background()
if err := client.Refresh(ctx); err != nil {
panic(err)
}
reqCtx := context.WithValue(ctx, userKey{}, user{ID: "u1", Org: "acme", Plan: "pro"})
fmt.Println(client.Enabled(reqCtx, "pro-reports", false))
}
Output: true
type Condition ¶
type Condition struct {
Attribute string `json:"attribute"`
Op Operator `json:"op"`
Value any `json:"value,omitempty"`
Values []any `json:"values,omitempty"`
}
Condition compares one attribute of the EvalContext. OpIn and OpNotIn use Values; every other operator uses Value. Values must be strings, booleans or finite numbers; the ordering operators require numbers.
type Distribution ¶
type Distribution struct {
// BucketBy defaults to BucketByUser when empty. Contexts without the
// chosen identifier are not bucketed.
BucketBy BucketBy `json:"bucketBy,omitempty"`
// Salt changes the bucket of every context when changed, which reshuffles
// a rollout. At most 128 bytes, without NUL bytes.
Salt string `json:"salt,omitempty"`
// Weights must name existing variants, at most once each, with
// non-negative weights summing to TotalWeight.
Weights []Weight `json:"weights"`
}
Distribution splits contexts across variants by weight. Each context lands in a bucket in [0, TotalWeight) computed by Bucket from the flag key, Salt and the bucketing key; weights claim consecutive bucket ranges in list order.
Because the ranges are consecutive, raising the weight of the first variant only moves contexts into it: list the variant whose share grows (usually VariantOn) first and a rollout from 10% to 20% keeps the original 10%.
func PercentageRollout ¶
func PercentageRollout(basisPoints int) *Distribution
PercentageRollout returns a Distribution that serves VariantOn to the given share of users, in basis points (2500 is 25%), and VariantOff to the rest. Values outside [0, TotalWeight] produce a distribution that fails validation.
Example ¶
package main
import (
"fmt"
featureflags "github.com/byteminus/go-featureflags"
)
func main() {
flag := featureflags.NewBoolFlag("new-checkout", "")
flag.Rollout = featureflags.PercentageRollout(2500) // 25% of users
snapshot, err := featureflags.NewSnapshot([]featureflags.Flag{flag})
if err != nil {
panic(err)
}
// user-1 is in bucket 1479, below 2500; user-2 is in bucket 8982.
for _, id := range []string{"user-1", "user-2"} {
r := snapshot.Evaluate("new-checkout", featureflags.EvalContext{UserID: id})
fmt.Println(id, r.Variant.Name, r.Reason)
}
}
Output: user-1 on rollout user-2 off rollout
type EvalContext ¶
type EvalContext struct {
// UserID feeds user targets and user bucketing.
UserID string
// TenantID feeds tenant targets and tenant bucketing.
TenantID string
// Attributes feed rule conditions. Values are typically strings,
// booleans, numbers or slices of those. The map is read, never written,
// by evaluation; do not modify it after placing it in a context.
Attributes map[string]any
}
EvalContext describes who a flag is evaluated for. All fields are optional: targeting and bucketing by an empty ID never match, and conditions on an absent attribute never hold.
func EvalContextFrom ¶
func EvalContextFrom(ctx context.Context) (EvalContext, bool)
EvalContextFrom returns the EvalContext stored by WithEvalContext. The boolean is false when ctx carries none, in which case the EvalContext is zero.
type EvaluationEvent ¶
type EvaluationEvent struct {
Result Result
Context EvalContext
}
EvaluationEvent is passed to ClientOptions.OnEvaluation after every Client evaluation, for analytics and exposure logging.
type Flag ¶
type Flag struct {
// Key identifies the flag: 1 to 128 characters from a-z, 0-9, '.', '_'
// and '-', starting with a letter or digit.
Key string `json:"key"`
// Description is free text for the people who manage the flag.
Description string `json:"description,omitempty"`
// Enabled is the kill switch. When false, rules are ignored and every
// evaluation serves DefaultVariant with ReasonDisabled.
Enabled bool `json:"enabled"`
// Variants lists the values the flag can serve. Names are unique.
Variants []Variant `json:"variants"`
// DefaultVariant names the variant served when the flag is disabled or
// when no target, rule or rollout chooses a variant.
DefaultVariant string `json:"defaultVariant"`
// Targets serve a fixed variant to listed users or tenants. They are
// checked in order before any rule.
Targets []Target `json:"targets,omitempty"`
// Rules are checked in order after Targets; the first rule whose
// conditions all hold and that serves a variant wins.
Rules []Rule `json:"rules,omitempty"`
// Rollout, when set, distributes every context that no target or rule
// claimed across variants by deterministic bucketing.
Rollout *Distribution `json:"rollout,omitempty"`
// Version is the optimistic concurrency token assigned by the Store. It
// starts at 1 and grows by one on every update.
Version int64 `json:"-"`
// UpdatedAt is the UTC time of the last write, assigned by the Store.
UpdatedAt time.Time `json:"-"`
}
Flag is the complete definition of one feature flag.
Evaluation of an enabled flag walks Targets, then Rules, then Rollout, and serves the first variant chosen; when nothing chooses one it serves DefaultVariant. A disabled flag (the kill switch) always serves DefaultVariant, so the default should be the safe, "feature off" variant.
Version and UpdatedAt are maintained by the Store and are not part of the JSON definition.
Validate bounds the size of a definition, because definitions may come from an admin API and every request evaluates them: a description of at most 4096 bytes; at most 100 variants with payloads of at most 64 KiB; at most 100 targets holding at most 10000 user and tenant IDs in total, each of 1 to 256 bytes; at most 100 rules with at most 20 conditions each; at most 10000 condition values (Values of in and not_in) in total; and condition strings of at most 1024 bytes.
Example (Variants) ¶
package main
import (
"fmt"
featureflags "github.com/byteminus/go-featureflags"
)
func main() {
flag := featureflags.Flag{
Key: "pricing-page",
Enabled: true,
Variants: []featureflags.Variant{
{Name: "control"},
{Name: "annual-first", Payload: []byte(`{"highlight":"annual"}`)},
},
DefaultVariant: "control",
Rollout: &featureflags.Distribution{
BucketBy: featureflags.BucketByTenant,
Weights: []featureflags.Weight{
{Variant: "control", Weight: 5000},
{Variant: "annual-first", Weight: 5000},
},
},
}
snapshot, err := featureflags.NewSnapshot([]featureflags.Flag{flag})
if err != nil {
panic(err)
}
// Every user of a tenant sees the same arm, and the arm never changes.
a := snapshot.Evaluate("pricing-page", featureflags.EvalContext{UserID: "u1", TenantID: "tenant-42"})
b := snapshot.Evaluate("pricing-page", featureflags.EvalContext{UserID: "u2", TenantID: "tenant-42"})
fmt.Println(a.Variant.Name == b.Variant.Name)
}
Output: true
func NewBoolFlag ¶
NewBoolFlag returns an enabled boolean flag with variants VariantOn and VariantOff that serves VariantOff until targets, rules or a rollout are added.
func ParseJSON ¶
ParseJSON reads a config-as-code document of the form {"flags": [...]}, where each element is a flag definition in the JSON form of Flag. Decoding is strict: unknown fields, trailing data, invalid definitions and duplicate keys are errors, so a typo fails the deploy instead of silently changing behaviour. Every flag of the document is validated and all problems are reported together. Field names match case-insensitively, as everywhere in encoding/json.
ParseJSON reads at most 32 MiB. Every error wraps ErrInvalid; invalid definitions also wrap ErrInvalidFlag. On error no flags are returned, so a FileSource with a broken file keeps the Client on its last good snapshot.
Example ¶
package main
import (
"fmt"
"strings"
featureflags "github.com/byteminus/go-featureflags"
)
func main() {
doc := `{"flags": [{
"key": "dark-mode",
"enabled": true,
"variants": [{"name": "on"}, {"name": "off"}],
"defaultVariant": "off",
"rules": [{"conditions": [{"attribute": "beta", "op": "eq", "value": true}], "variant": "on"}]
}]}`
flags, err := featureflags.ParseJSON(strings.NewReader(doc))
if err != nil {
panic(err)
}
snapshot, err := featureflags.NewSnapshot(flags)
if err != nil {
panic(err)
}
r := snapshot.Evaluate("dark-mode", featureflags.EvalContext{Attributes: map[string]any{"beta": true}})
fmt.Println(r.Variant.Name, r.Reason)
}
Output: on rule_match
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager is the management API: it validates definitions before they reach the Store and emits change events. It is safe for concurrent use, because every Store is.
Example ¶
ExampleManager shows the management flow: validated writes, optimistic concurrency and change events that name the actor.
package main
import (
"context"
"errors"
"fmt"
featureflags "github.com/byteminus/go-featureflags"
"github.com/byteminus/go-featureflags/memstore"
)
func main() {
ctx := context.Background()
store := memstore.New()
manager, err := featureflags.NewManager(store, featureflags.ManagerOptions{
OnChange: func(_ context.Context, e featureflags.ChangeEvent) error {
fmt.Println(e.Type, e.Key, "by", e.Actor.Type, e.Actor.ID)
return nil
},
})
if err != nil {
panic(err)
}
ctx = featureflags.WithActor(ctx, featureflags.Ref{Type: "user", ID: "42"})
created, err := manager.Create(ctx, featureflags.NewBoolFlag("beta", ""))
if err != nil {
panic(err)
}
alice, bob := created.Clone(), created.Clone()
alice.Rollout = featureflags.PercentageRollout(1000)
bob.Enabled = false
if _, err := manager.Update(ctx, alice); err != nil {
panic(err)
}
// Bob edited the same version Alice did, so his write is refused
// instead of silently undoing hers.
_, err = manager.Update(ctx, bob)
fmt.Println(errors.Is(err, featureflags.ErrVersionConflict), errors.Is(err, featureflags.ErrConflict))
}
Output: created beta by user 42 updated beta by user 42 true true
func NewManager ¶
func NewManager(store Store, opts ManagerOptions) (*Manager, error)
NewManager returns a Manager writing to store. A nil store is an error wrapping ErrInvalid.
func (*Manager) Create ¶
Create validates and stores a new flag at version 1. It returns an error wrapping ErrInvalidFlag for an invalid definition, ErrInvalid for a half-empty actor, and ErrFlagExists for a taken key.
func (*Manager) Delete ¶
Delete removes key, provided version is the version currently stored. It returns an error wrapping ErrFlagNotFound, ErrVersionConflict or, for a half-empty actor, ErrInvalid.
func (*Manager) List ¶
List returns every stored flag ordered by key. Flags are a small, curated set, so the list is not paginated.
func (*Manager) Update ¶
Update validates f and replaces the stored definition, provided f.Version is the version currently stored; otherwise it returns an error wrapping ErrVersionConflict and changes nothing. Read, modify, then Update with the version you read: of two concurrent updates from the same version, the second always fails instead of silently overwriting the first. Other errors are as for Create, plus ErrFlagNotFound for a missing key.
type ManagerOptions ¶
type ManagerOptions struct {
// OnChange runs after every successful write with the write's context.
// When the write joins a caller's database transaction (see
// pgxstore.WithConn), the hook can record the audit entry in the
// same transaction; an error returned by the hook is returned by the
// write method, together with the stored flag, so the caller can roll
// back. Without a surrounding transaction the write has already happened
// when the hook fails.
OnChange func(ctx context.Context, e ChangeEvent) error
// ActorFrom returns the party performing a write, recorded in
// ChangeEvent.Actor. Nil means the package-level ActorFrom, which reads
// the value stored by WithActor.
ActorFrom func(ctx context.Context) (Ref, bool)
// Now stamps ChangeEvent.At; time.Now when nil.
Now func() time.Time
}
ManagerOptions configures a Manager. The zero value is ready to use.
type Notifier ¶
Notifier pushes change signals so a running Client refreshes without waiting for the next poll.
Applications and stores implement Notifier. Watch blocks until ctx is done, calling changed whenever flags may have changed, and also after every reconnect, since changes made while disconnected were missed. changed is cheap, never blocks and may be called from any goroutine. Watch returns nil when ctx is done, and an error only when it cannot continue at all; it must not keep running after it returns.
type Operator ¶
type Operator string
Operator is the comparison a Condition applies to an attribute.
const ( // OpEquals holds when the attribute equals Value. OpEquals Operator = "eq" // OpNotEquals holds when the attribute is a usable scalar that differs // from Value. OpNotEquals Operator = "neq" // OpIn holds when the attribute equals one of Values. OpIn Operator = "in" // OpNotIn holds when the attribute is a usable scalar that equals none of // Values. OpNotIn Operator = "not_in" // OpContains holds when a string attribute contains the string Value as a // substring (the empty string is in every string), or when a slice or // array attribute has an element equal to Value. It never holds for // other attributes. OpContains Operator = "contains" // OpLessThan holds when a numeric attribute is less than the number Value. OpLessThan Operator = "lt" // OpLessOrEqual holds when a numeric attribute is at most Value. OpLessOrEqual Operator = "lte" // OpGreaterThan holds when a numeric attribute is greater than Value. OpGreaterThan Operator = "gt" // OpGreaterOrEqual holds when a numeric attribute is at least Value. OpGreaterOrEqual Operator = "gte" )
Supported operators and their semantics:
- Kinds. Attributes and values are compared as one of three kinds: string, boolean and number. Every Go numeric type and json.Number is a number (converted to float64, so integers beyond 2^53 lose precision), so an int attribute equals a float64 value of the same magnitude. Values of different kinds are never equal: the string "12" is not the number 12, and nothing is parsed or coerced.
- Case. String comparisons, including OpContains, are exact and case-sensitive byte comparisons; normalize case in the EvalContext if the application wants case-insensitive targeting.
- Missing data. A condition on an attribute that is absent from the EvalContext, or nil, never holds, for every operator including OpNotEquals and OpNotIn, so missing data never opts anyone in. The same applies to an attribute that is not a string, boolean or finite number (a map, a struct, NaN) for every operator except OpContains.
- Mismatched kinds. OpEquals and OpIn do not hold, and OpNotEquals and OpNotIn hold, because a value of another kind differs. The ordering operators hold only for numeric attributes.
type Reason ¶
type Reason string
Reason explains why an evaluation served its variant.
const ( // ReasonFlagNotFound: the snapshot has no flag with the key. The result // carries the zero Variant, and Client.Enabled reports false. ReasonFlagNotFound Reason = "flag_not_found" // ReasonDisabled: the kill switch is off; the default variant is served. ReasonDisabled Reason = "disabled" // ReasonTargetMatch: the user or tenant is listed in Targets[Index]. ReasonTargetMatch Reason = "target_match" // ReasonRuleMatch: Rules[Index] matched and served the variant. ReasonRuleMatch Reason = "rule_match" // ReasonRollout: the flag-level Rollout distribution chose the variant. ReasonRollout Reason = "rollout" // ReasonDefault: nothing chose a variant; the default variant is served. ReasonDefault Reason = "default" )
Evaluation reasons.
type Ref ¶ added in v0.2.0
type Ref struct {
// Type names the kind of record or party, such as "user", "api_token"
// or "job".
Type string `json:"type"`
// ID identifies the record or party within its Type.
ID string `json:"id"`
}
Ref identifies a record or party by type and ID, for example Ref{Type: "user", ID: "42"} or Ref{Type: "api_token", ID: "42"}, which never collide. The zero Ref means "nobody"; a Ref with only one of its fields set is rejected with an error wrapping ErrInvalid wherever the package accepts one.
func ActorFrom ¶
ActorFrom returns the actor stored by WithActor. The boolean is false when ctx carries none, in which case the Ref is zero.
type Result ¶
type Result struct {
// Key is the evaluated flag key.
Key string
// Variant is the served variant; the zero Variant when the flag was not
// found. Its payload is shared with the snapshot and must not be modified.
Variant Variant
// Reason explains the choice.
Reason Reason
// Index is the position of the matching target (ReasonTargetMatch) or
// rule (ReasonRuleMatch) and -1 for every other reason.
Index int
// Version is the version of the flag definition that was evaluated, 0
// when the flag was not found or was never stored.
Version int64
}
Result is the outcome of evaluating one flag.
type Rule ¶
type Rule struct {
Conditions []Condition `json:"conditions,omitempty"`
Variant string `json:"variant,omitempty"`
Distribution *Distribution `json:"distribution,omitempty"`
}
Rule serves a variant to contexts that satisfy all of its Conditions. A rule with no conditions matches every context.
Exactly one of Variant and Distribution is set. A rule with a Distribution only applies to contexts that have the distribution's bucketing key; other contexts move on to the next rule.
type Snapshot ¶
type Snapshot struct {
// contains filtered or unexported fields
}
Snapshot is an immutable, validated set of flags evaluated entirely in memory. It is safe for concurrent use. The zero Snapshot holds no flags.
func NewSnapshot ¶
NewSnapshot validates and compiles flags into a Snapshot. It fails if any flag is invalid or two flags share a key. The flags are copied, so the caller may reuse the slice.
func (*Snapshot) Evaluate ¶
func (s *Snapshot) Evaluate(key string, ec EvalContext) Result
Evaluate resolves the flag key for ec. A missing flag yields ReasonFlagNotFound and the zero Variant. Evaluate never blocks and never performs I/O.
type Source ¶
Source loads the complete set of flag definitions. Every Store is a Source; FileSource and SourceFunc adapt other origins.
Applications implement Source to feed a Client from their own origin. Load must return every flag that currently exists (a key that is absent is treated as deleted) and must honour ctx. An error means nothing could be loaded, and the Client keeps its current snapshot. Load may return definitions that fail Validate: the Client rejects those one by one and keeps the others.
func FileSource ¶
FileSource returns a Source that reads and parses the named file from fsys with ParseJSON on every Load, so a polling Client picks up edits. Use os.DirFS for files on disk, or an embed.FS to ship flags in the binary. Replace the file atomically (write a temporary file, then rename it) so a refresh never reads a partial file; a partial file would fail to parse and the Client would keep its current snapshot.
Example ¶
ExampleFileSource feeds a Client from a config-as-code file; os.DirFS or an embed.FS work the same way.
package main
import (
"context"
"fmt"
"testing/fstest"
featureflags "github.com/byteminus/go-featureflags"
)
func main() {
fsys := fstest.MapFS{"flags.json": {Data: []byte(`{"flags": [{
"key": "maintenance-banner",
"enabled": true,
"variants": [{"name": "on"}, {"name": "off"}],
"defaultVariant": "on"
}]}`)}}
client, err := featureflags.NewClient(featureflags.FileSource(fsys, "flags.json"), featureflags.ClientOptions{})
if err != nil {
panic(err)
}
if err := client.Refresh(context.Background()); err != nil {
panic(err)
}
fmt.Println(client.Enabled(context.Background(), "maintenance-banner", false))
}
Output: true
type SourceFunc ¶
SourceFunc adapts a function to the Source interface.
type Store ¶
type Store interface {
// Load returns every stored flag ordered by key.
Source
// Get returns the flag stored under key, or an error wrapping
// ErrFlagNotFound.
Get(ctx context.Context, key string) (Flag, error)
// Create stores a new flag at version 1, ignoring f.Version, and returns
// it with Version and UpdatedAt set. A malformed key is an error
// wrapping ErrInvalidFlag; a key that exists is an error wrapping
// ErrFlagExists.
Create(ctx context.Context, f Flag) (Flag, error)
// Update replaces the definition of f.Key if its stored version equals
// f.Version, and returns it with the incremented Version and new
// UpdatedAt. The comparison and the write are one atomic step, so of
// several concurrent updates from the same version exactly one
// succeeds; the others get an error wrapping ErrVersionConflict. A
// missing key is an error wrapping ErrFlagNotFound.
Update(ctx context.Context, f Flag) (Flag, error)
// Delete removes key if its stored version equals version, atomically,
// with the same errors as Update.
Delete(ctx context.Context, key string, version int64) error
}
Store persists flag definitions with optimistic concurrency.
Storage backends implement Store; memstore and pgxstore are the two that ship with this module, and storetest.Run verifies any implementation against the guarantees below. Applications write through a Manager, which validates definitions before they reach the Store and emits change events.
Every method honours ctx and is safe for concurrent use. Returned flags share no memory with the Store or with the caller's input, carry the stored Version and an UpdatedAt in UTC truncated to the microsecond (the precision of PostgreSQL), and come back with the same Go types a JSON round trip produces (condition numbers as float64).
type Target ¶
type Target struct {
Variant string `json:"variant"`
Users []string `json:"users,omitempty"`
Tenants []string `json:"tenants,omitempty"`
}
Target serves Variant to every context whose user ID is in Users or whose tenant ID is in Tenants.
type ValidationError ¶
type ValidationError struct {
// Key is the key of the rejected definition, as given.
Key string
// Problems describes each failed rule, naming the offending field.
Problems []string
}
ValidationError lists every problem found in one flag definition. It matches ErrInvalidFlag and ErrInvalid with errors.Is.
func (*ValidationError) Error ¶
func (e *ValidationError) Error() string
Error returns all problems on one line.
func (*ValidationError) Unwrap ¶ added in v0.2.0
func (e *ValidationError) Unwrap() error
Unwrap returns ErrInvalidFlag, which makes errors.Is reach both ErrInvalidFlag and ErrInvalid.
type Variant ¶
type Variant struct {
// Name is 1 to 64 characters from A-Z, a-z, 0-9, '.', '_' and '-',
// starting with a letter or digit.
Name string `json:"name"`
// Payload is optional JSON. Payloads returned by evaluation are shared
// with the snapshot and must not be modified.
Payload json.RawMessage `json:"payload,omitempty"`
}
Variant is one value a flag can serve: a name plus an optional JSON payload, for example a configuration object for an experiment arm.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package featureflagstest gives application tests a real *featureflags.Client whose flags they control directly, without a store, polling or background goroutines.
|
Package featureflagstest gives application tests a real *featureflags.Client whose flags they control directly, without a store, polling or background goroutines. |
|
Package memstore is an in-memory featureflags.Store for tests, examples and single-process tools.
|
Package memstore is an in-memory featureflags.Store for tests, examples and single-process tools. |
|
Package pgxstore is a PostgreSQL featureflags.Store built on pgx v5, with a LISTEN/NOTIFY Listener that pushes changes to running clients.
|
Package pgxstore is a PostgreSQL featureflags.Store built on pgx v5, with a LISTEN/NOTIFY Listener that pushes changes to running clients. |
|
Package storetest is a conformance suite for featureflags.Store implementations.
|
Package storetest is a conformance suite for featureflags.Store implementations. |