featureflags

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 16 Imported by: 0

README

go-featureflags

tests Go Reference

Feature toggles and gradual rollouts for Go SaaS applications. Flags are evaluated against an immutable in-memory snapshot, so a request never waits on the database: kill switches, user and tenant targeting, attribute rules, sticky percentage rollouts and weighted experiments resolve in roughly 100 ns without allocating. Snapshots refresh by polling and, with PostgreSQL, by push through LISTEN/NOTIFY. Writes go through a validating management API with optimistic concurrency and audit hooks.

Install

go get github.com/byteminus/go-featureflags

Requires Go 1.26 or later.

Quick start

package main

import (
	"context"
	"log"
	"os"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	featureflags "github.com/byteminus/go-featureflags"
	"github.com/byteminus/go-featureflags/pgxstore"
)

func main() {
	ctx := context.Background()
	url := os.Getenv("DATABASE_URL")
	pool, err := pgxpool.New(ctx, url)
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()

	store, err := pgxstore.New(pool)
	if err != nil {
		log.Fatal(err)
	}
	if err := store.CreateSchema(ctx); err != nil {
		log.Fatal(err)
	}

	// Management: validated writes with optimistic concurrency.
	manager, err := featureflags.NewManager(store, featureflags.ManagerOptions{})
	if err != nil {
		log.Fatal(err)
	}
	flag := featureflags.NewBoolFlag("new-checkout", "Redesigned checkout flow")
	flag.Targets = []featureflags.Target{{Variant: featureflags.VariantOn, Tenants: []string{"acme"}}}
	flag.Rollout = featureflags.PercentageRollout(1000) // 10% of everyone else
	if _, err := manager.Create(featureflags.WithActor(ctx, "ops@example.com"), flag); err != nil {
		log.Print(err) // already exists on the second run
	}

	// Evaluation: an in-memory snapshot, refreshed by push and polling.
	listener, err := store.Listener(func(ctx context.Context) (*pgx.Conn, error) {
		return pgx.Connect(ctx, url)
	})
	if err != nil {
		log.Fatal(err)
	}
	client, err := featureflags.New(store, featureflags.Options{Notifier: listener})
	if err != nil {
		log.Fatal(err)
	}
	if err := client.Refresh(ctx); err != nil {
		log.Fatal(err)
	}
	runCtx, stop := context.WithCancel(ctx)
	defer stop()
	go func() { _ = client.Run(runCtx) }()

	// Per request, usually in middleware after authentication:
	reqCtx := featureflags.WithEvalContext(ctx, featureflags.EvalContext{
		UserID:     "user-1",
		TenantID:   "acme",
		Attributes: map[string]any{"plan": "pro"},
	})
	r := client.Evaluate(reqCtx, "new-checkout")
	log.Printf("enabled=%v variant=%s reason=%s", client.Enabled(reqCtx, "new-checkout"), r.Variant.Name, r.Reason)
}

Concepts

Flag. A key (a-z, 0-9, ., _, -; up to 128 characters), a description, an Enabled kill switch, named Variants with optional JSON payloads, and a DefaultVariant. Boolean flags use the variants on and off; Client.Enabled is true exactly when the result is on.

Evaluation order. For an enabled flag, the first match wins:

Step Matches when Reason
Targets[i] the user ID or tenant ID is listed target_match, Index = i
Rules[i] every condition holds (and, for a distribution, the context has the bucketing key) rule_match, Index = i
Rollout the context has the bucketing key rollout
default always default

A disabled flag serves its default with reason disabled. An unknown key gives flag_not_found and the zero Variant.

Conditions. eq, neq, in, not_in, contains (substring, or slice element) and the numeric lt, lte, gt, gte. All Go numeric types compare as numbers. A condition on an attribute the context does not carry never holds, including neq and not_in, so missing data never opts someone in.

Bucketing. A Distribution assigns weights in basis points (summing to 10000) to variants and buckets by user ID (default) or tenant ID. The bucket is

uint64(big-endian first 8 bytes of SHA-256(flagKey 0x00 salt 0x00 bucketingKey)) mod 10000

which is stable across processes, platforms, languages and releases; a test pins known values. Weights claim consecutive bucket ranges in list order, so growing the first variant's share only adds users. Change the Salt to reshuffle.

Validation. Validate reports every problem at once: malformed keys or variant names, duplicate variants, invalid payload JSON, references to unknown variants, malformed conditions, and weights that do not sum to 10000.

Safe defaults. Evaluation never touches the source, never blocks and never panics. Until the first refresh, and for keys the source does not define, Enabled is false. A failing refresh keeps the last good snapshot and reports through Options.OnError. A stored definition that fails validation keeps the previous version of that flag instead of turning it off.

Hooks. Options.OnEvaluation receives every evaluation (for exposure analytics); ManagerOptions.OnChange receives every create, update and delete with the actor from WithActor (for audit).

Config as code. ParseJSON and FileSource read {"flags": [...]} documents strictly (unknown fields are errors). A FileSource can feed a Client directly, with polling picking up edits.

PostgreSQL

pgxstore stores one row per flag. Store.Schema() returns idempotent DDL; run it with CreateSchema or copy it into a migration:

CREATE TABLE IF NOT EXISTS "feature_flags" (
    key        text        PRIMARY KEY CHECK (key ~ '^[a-z0-9][a-z0-9._-]{0,127}$'),
    definition jsonb       NOT NULL CHECK (jsonb_typeof(definition) = 'object' AND definition->>'key' = key),
    version    bigint      NOT NULL CHECK (version >= 1),
    updated_at timestamptz NOT NULL
);

Options: WithTable("schema.table"), WithChannel(name) (the NOTIFY channel, default feature_flags), WithNow(clock) and WithConn.

Every write is a single conditional statement that also calls pg_notify, so two writers holding the same version cannot both succeed, and a write inside a transaction notifies only on commit. To make writes join a transaction you carry in the context, resolve the connection per call:

type txKey struct{}

store, err := pgxstore.New(pool, pgxstore.WithConn(func(ctx context.Context) pgxstore.Querier {
	if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
		return tx
	}
	return nil // use the pool
}))

A ManagerOptions.OnChange hook can then write its audit row with store.Querier(ctx) in the same transaction, and return an error to make the caller roll back.

store.Listener(connect) returns a featureflags.Notifier that holds one dedicated connection running LISTEN. It pings an idle connection every 30 seconds, reconnects with jittered exponential backoff (WithBackoff), and signals a refresh after every reconnect so nothing missed while disconnected is lost. Keep polling enabled as the safety net.

API overview

Package Contents
featureflags Flag, Variant, Target, Rule, Condition, Distribution; Validate, Bucket; Snapshot; Client (Enabled, Variant, Evaluate, Refresh, Run); Manager; Store, Source, Notifier; ParseJSON, FileSource; WithEvalContext, WithActor
memstore In-memory Store and Notifier (not transactional)
pgxstore PostgreSQL Store and LISTEN/NOTIFY Listener
storetest Run(t, newStore) conformance suite for Store implementations
featureflagstest New(t) returns overridable flags behind a real Client

Testing

In application tests, use featureflagstest instead of a store:

flags := featureflagstest.New(t)
flags.Enable("new-checkout")
flags.SetVariant("pricing-page", "annual-first", map[string]string{"highlight": "annual"})
handler := NewHandler(flags.Client())

Changes apply synchronously. To verify your own Store, run storetest.Run(t, newStore) from a test.

To test this module, see CONTRIBUTING.md; the PostgreSQL tests need TEST_DATABASE_URL.

Roadmap

  • Targeted refresh: reload only the flag named in a notification instead of the full set.
  • Segments: reusable, named groups of conditions shared across flags.
  • Semantic version and date comparison operators.
  • Prerequisite flags (a flag that only applies when another resolves to a given variant).
  • Syncing a config-as-code file into a Store through the Manager.
  • Flag history (previous versions) in pgxstore.

License

MIT

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:

  1. Targets: explicit user or tenant IDs (ReasonTargetMatch).
  2. 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).
  3. Rollout: a flag-level Distribution for everyone else (ReasonRollout).
  4. DefaultVariant (ReasonDefault).

A disabled flag serves DefaultVariant with ReasonDisabled, and a key the snapshot does not contain yields ReasonFlagNotFound with the zero Variant.

Distributions use deterministic, sticky bucketing: see Bucket for the documented hash, which is stable across processes, platforms and releases.

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). Evaluation reads only the snapshot, never blocks and never panics on missing flags or a broken source: missing flags are "off" (Enabled reports false) and a failing refresh keeps the last good snapshot.

Management

A Manager validates definitions, writes them to a Store with optimistic concurrency (Flag.Version; a stale write fails with ErrVersionConflict) and emits ChangeEvents 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.

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.New(store, featureflags.Options{})
	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"), r.Reason, r.Index)
	}
	fmt.Println(client.Enabled(ctx, "no-such-flag"))
}
Output:
qa-lead true target_match 0
u-17 true rule_match 0
u-18 false default -1
false

Index

Examples

Constants

View Source
const (
	DefaultPollInterval   = 30 * time.Second
	DefaultRefreshTimeout = 10 * time.Second
)

Defaults applied by New for zero Options fields.

View Source
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.

View Source
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

View Source
var (
	// ErrFlagNotFound reports that no flag exists with the requested key.
	ErrFlagNotFound = errors.New("featureflags: flag not found")
	// ErrFlagExists reports a Create for a key that is already taken.
	ErrFlagExists = errors.New("featureflags: flag already exists")
	// ErrVersionConflict reports a write whose expected version is not the
	// stored version: someone else changed the flag since it was read.
	ErrVersionConflict = errors.New("featureflags: version conflict")
	// ErrInvalidFlag reports a definition that fails validation. The
	// concrete error is a *ValidationError when it comes from Validate.
	ErrInvalidFlag = errors.New("featureflags: invalid flag")
	// ErrNoPayload reports Variant.Decode on a variant without a payload.
	ErrNoPayload = errors.New("featureflags: variant has no payload")
	// ErrAlreadyRunning reports a second concurrent call to Client.Run.
	ErrAlreadyRunning = errors.New("featureflags: client is already running")
)

Sentinel errors. Stores and the Manager wrap them, so check with errors.Is.

Functions

func ActorFrom

func ActorFrom(ctx context.Context) string

ActorFrom returns the actor carried by ctx, or "" when there is none.

func Bucket

func Bucket(flagKey, salt, bucketingKey string) int

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

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

func Validate(f Flag) error

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 and distributions whose weights sum to TotalWeight. The error is a *ValidationError listing every problem.

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

func ValidateKey(key string) error

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

func WithActor(ctx context.Context, actor string) context.Context

WithActor returns a copy of ctx carrying the identity of whoever performs management writes; it is recorded 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.

const (
	BucketByUser   BucketBy = "user"
	BucketByTenant BucketBy = "tenant"
)

Bucketing keys. Bucketing by tenant keeps every user of a tenant on the same variant, which is usually what a B2B rollout wants.

type ChangeEvent

type ChangeEvent struct {
	Type ChangeType
	Key  string
	// Actor comes from WithActor on the write's context.
	Actor string
	// Before is nil for ChangeCreated; After is nil for ChangeDeleted.
	Before *Flag
	After  *Flag
	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, evaluation reports ReasonFlagNotFound, Enabled reports false and Variant returns the zero Variant. A failing source leaves the last good snapshot in place. A Client is safe for concurrent use.

func New

func New(source Source, opts Options) (*Client, error)

New 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.

func (*Client) Enabled

func (c *Client) Enabled(ctx context.Context, key string) bool

Enabled reports whether key resolves to VariantOn for the EvalContext carried by ctx. It is false for missing flags.

func (*Client) Evaluate

func (c *Client) Evaluate(ctx context.Context, key string) Result

Evaluate resolves key for the EvalContext carried by ctx and reports the variant together with the reason it was chosen.

func (*Client) LastRefresh

func (c *Client) LastRefresh() time.Time

LastRefresh returns when the snapshot was last replaced, or the zero time if no refresh has succeeded.

func (*Client) Refresh

func (c *Client) Refresh(ctx context.Context) error

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.

func (*Client) Run

func (c *Client) Run(ctx context.Context) error

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. Refresh errors go to OnError and never stop the loop. Run returns nil after ctx is done and its notifier has stopped, or ErrAlreadyRunning if another Run is active.

func (*Client) Snapshot

func (c *Client) Snapshot() *Snapshot

Snapshot returns the snapshot currently used for evaluation.

func (*Client) Variant

func (c *Client) Variant(ctx context.Context, key string) Variant

Variant returns the variant key resolves to for the EvalContext carried by ctx, or the zero Variant for missing flags.

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

EvalContextFrom returns the EvalContext carried by ctx, or the zero EvalContext when there is none.

type EvaluationEvent

type EvaluationEvent struct {
	Result  Result
	Context EvalContext
}

EvaluationEvent is passed to Options.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.

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

func NewBoolFlag(key, description string) Flag

NewBoolFlag returns an enabled boolean flag with variants VariantOn and VariantOff that serves VariantOff until targets, rules or a rollout are added.

func ParseJSON

func ParseJSON(r io.Reader) ([]Flag, error)

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.

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

func (Flag) Clone

func (f Flag) Clone() Flag

Clone returns a deep copy of f. Condition values are copied element by element; validated values are scalars, so the copy shares no memory with f.

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 if the Store is.

func NewManager

func NewManager(store Store, opts ManagerOptions) (*Manager, error)

NewManager returns a Manager writing to store.

func (*Manager) Create

func (m *Manager) Create(ctx context.Context, f Flag) (Flag, error)

Create validates and stores a new flag at version 1.

func (*Manager) Delete

func (m *Manager) Delete(ctx context.Context, key string, version int64) error

Delete removes key, provided version is the version currently stored.

func (*Manager) Get

func (m *Manager) Get(ctx context.Context, key string) (Flag, error)

Get returns the stored flag, or an error wrapping ErrFlagNotFound.

func (*Manager) List

func (m *Manager) List(ctx context.Context) ([]Flag, error)

List returns every stored flag ordered by key.

func (*Manager) Update

func (m *Manager) Update(ctx context.Context, f Flag) (Flag, error)

Update validates f and replaces the stored definition, provided f.Version is the version currently stored; otherwise it returns an error wrapping ErrVersionConflict. Read, modify, then Update with the version you read.

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
	// 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

type Notifier interface {
	Watch(ctx context.Context, changed func()) error
}

Notifier pushes change signals so a running Client refreshes without waiting for the next poll. Watch blocks until ctx is done, calling changed whenever flags may have changed (also after every reconnect, since changes made while disconnected were missed). changed must be cheap and must not block. Watch returns nil when ctx is done and an error only when it cannot continue at all.

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 present and differs from Value.
	OpNotEquals Operator = "neq"
	// OpIn holds when the attribute equals one of Values.
	OpIn Operator = "in"
	// OpNotIn holds when the attribute is present and equals none of Values.
	OpNotIn Operator = "not_in"
	// OpContains holds when a string attribute contains the string Value as a
	// substring, or when a slice attribute has an element equal to Value.
	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. Equality treats all Go numeric types and json.Number as the same number, so an int attribute equals a float64 value of the same magnitude. Values of different kinds (string against number) are never equal. A condition on an attribute that is absent from the EvalContext, or nil, never holds, for every operator including OpNotEquals and OpNotIn.

type Options

type Options 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 means DefaultRefreshTimeout.
	RefreshTimeout time.Duration
	// Notifier, when set, triggers refreshes from pushed change signals
	// while Run is active.
	Notifier Notifier
	// 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
}

Options configures a Client. The zero value is ready to use.

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 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.

func (Result) On

func (r Result) On() bool

On reports whether the result served VariantOn.

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

func NewSnapshot(flags []Flag) (*Snapshot, error)

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.

func (*Snapshot) Flag

func (s *Snapshot) Flag(key string) (Flag, bool)

Flag returns a copy of the definition stored under key.

func (*Snapshot) Keys

func (s *Snapshot) Keys() []string

Keys returns the flag keys in ascending order.

func (*Snapshot) Len

func (s *Snapshot) Len() int

Len returns the number of flags.

type Source

type Source interface {
	Load(ctx context.Context) ([]Flag, error)
}

Source loads the complete set of flag definitions. Every Store is a Source; FileSource and SourceFunc adapt other origins.

func FileSource

func FileSource(fsys fs.FS, name string) Source

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.

type SourceFunc

type SourceFunc func(ctx context.Context) ([]Flag, error)

SourceFunc adapts a function to the Source interface.

func (SourceFunc) Load

func (fn SourceFunc) Load(ctx context.Context) ([]Flag, error)

Load calls fn.

type Store

type Store interface {
	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.
	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.
	Update(ctx context.Context, f Flag) (Flag, error)
	// Delete removes key if its stored version equals version.
	Delete(ctx context.Context, key string, version int64) error
}

Store persists flag definitions with optimistic concurrency. The storetest package holds a conformance suite for implementations.

Stores enforce key format (ErrInvalidFlag), uniqueness (ErrFlagExists) and version checks (ErrVersionConflict) themselves, but full definition validation is the Manager's job; write through a Manager.

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      string
	Problems []string
}

ValidationError lists every problem found in one flag definition. It matches ErrInvalidFlag with errors.Is.

func (*ValidationError) Error

func (e *ValidationError) Error() string

Error returns all problems on one line.

func (*ValidationError) Is

func (e *ValidationError) Is(target error) bool

Is makes errors.Is(err, ErrInvalidFlag) true.

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.

func (Variant) Decode

func (v Variant) Decode(dst any) error

Decode unmarshals the variant payload into dst. It returns ErrNoPayload when the variant carries no payload.

type Weight

type Weight struct {
	Variant string `json:"variant"`
	Weight  int    `json:"weight"`
}

Weight assigns a share of buckets, in basis points, to a variant.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL