Help us shape the future of Go! Take the 2026 Go Developer Survey

featureflags

package module
v0.2.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
	opsCtx := featureflags.WithActor(ctx, featureflags.Ref{Type: "user", ID: "ops@example.com"})
	if _, err := manager.Create(opsCtx, 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.NewClient(store, featureflags.ClientOptions{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")
	// The last argument is what a missing flag means to this caller.
	enabled := client.Enabled(reqCtx, "new-checkout", false)
	log.Printf("enabled=%v variant=%s reason=%s", enabled, 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, targets and rules are checked in list order and the first match wins, so evaluation is deterministic:

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 variant with reason disabled. An unknown key gives flag_not_found and the zero Variant; Client.Enabled and Client.Variant take the value to return in that case, so each caller decides what a missing flag means.

Conditions. eq, neq, in, not_in, contains (substring, or slice element) and the numeric lt, lte, gt, gte. Values are strings, booleans or numbers:

  • All Go numeric types compare as numbers (as float64). Kinds never mix: the string "12" is not the number 12, so eq and in fail and neq and not_in hold. Ordering operators hold only for numeric attributes.
  • String comparisons, contains included, are exact and case-sensitive.
  • A condition on an attribute the context does not carry, or carries as nil or as an unusable value (a map, NaN), never holds, including neq and not_in, so missing data never opts someone in.
  • There is no regular expression operator.

Bucketing. A Distribution assigns weights in basis points (summing to 10000) to variants and buckets by user ID (default) or tenant ID. The bucket of a context is part of the compatibility promise, because changing it would move users between variants of running rollouts:

input  = flagKey + 0x00 + salt + 0x00 + bucketingKey   (raw UTF-8 bytes, no normalization)
digest = SHA-256(input)
bucket = uint64(big-endian digest[0:8]) mod 10000

The variant listed at position i serves buckets from the sum of the weights before it (inclusive) to the sum including it (exclusive). So 0% serves nobody, 100% serves everybody, and growing the first variant's share only adds users: whoever was in at 10% is still in at 20%. The flag key is part of the hash, so each flag samples its own users; change the Salt to reshuffle one flag. Test vectors, pinned by TestBucketPinned and computed independently:

flag key salt bucketing key bucket
new-checkout user-1 1479
new-checkout user-2 8982
new-checkout v2 user-1 9307
pricing.page tenant-42 8132

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. It also bounds the size of a definition, since definitions may come from an admin API and every request evaluates them: a 4096-byte description, 100 variants with 64 KiB payloads, 100 targets holding 10000 IDs in total, 100 rules of up to 20 conditions, 10000 in/not_in values in total, and 1024-byte condition strings. A definition that passes Validate never makes evaluation panic.

Safe defaults. Evaluation never touches the source, never blocks and never panics. A failing refresh keeps the last good snapshot and reports through ClientOptions.OnError. A stored definition that fails validation keeps the previous version of that flag (or leaves a new flag out) while every other flag still refreshes.

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

Context values. The evaluation context and the actor travel in the context through WithEvalContext and WithActor. An application that already keeps its user in the context supplies the lookups instead, once, at startup; the hooks have the same signatures as EvalContextFrom and ActorFrom:

featureflags.ClientOptions{
	EvalContextFrom: func(ctx context.Context) (featureflags.EvalContext, bool) {
		u, ok := myapp.UserFrom(ctx)
		return featureflags.EvalContext{UserID: u.ID, TenantID: u.OrgID}, ok
	},
}
featureflags.ManagerOptions{
	ActorFrom: func(ctx context.Context) (featureflags.Ref, bool) {
		u, ok := myapp.UserFrom(ctx)
		return featureflags.Ref{Type: "user", ID: u.ID}, ok
	},
}

An actor is a Ref, a type and an ID, so a user and an API token with the same ID never collide. A Ref with only one field set is rejected.

Errors. Every error wraps one of ErrNotFound, ErrInvalid or ErrConflict (through ErrFlagNotFound, ErrInvalidFlag, ErrFlagExists and ErrVersionConflict), so map them to HTTP statuses with errors.Is.

Config as code. ParseJSON and FileSource read {"flags": [...]} documents of up to 32 MiB 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. A second writer holding the same version waits for the first one's row lock and then fails with ErrVersionConflict, so concurrent updates never lose one another, 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.

A row edited by hand so that its definition no longer decodes does not stop the other flags: Load returns it without variants, so a running Client keeps that flag's previous definition and reports it, and Get returns the decode error.

store.Listener(connect) returns a featureflags.Notifier that holds one dedicated connection running LISTEN. It bounds connecting by WithConnectTimeout (10 seconds), pings an idle connection every WithPingInterval (30 seconds), reconnects with jittered exponential backoff (WithBackoff), reports through WithErrorHandler, 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; Ref, 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, opts) returns overridable flags behind a real Client

Testing

In application tests, use featureflagstest instead of a store:

flags := featureflagstest.New(t, featureflags.ClientOptions{})
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.

Stability

This package follows semantic versioning. Before v1.0.0, a minor release may contain breaking changes; each one is listed in CHANGELOG.md with upgrade steps, including SQL for schema changes. From v1.0.0, the exported API and the PostgreSQL schema change only in backwards-compatible ways until the next major version.

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

Examples

Constants

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

Defaults applied by NewClient for zero ClientOptions 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 (
	// 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.

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

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

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

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

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 Ref) context.Context

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.

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

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

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

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

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

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

LastRefresh returns when the snapshot was last replaced, in UTC, 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 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

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

func (*Client) Snapshot

func (c *Client) Snapshot() *Snapshot

Snapshot returns the snapshot currently used for evaluation. It is never nil.

func (*Client) Variant

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

Variant returns the variant key resolves to for the EvalContext of ctx. When the snapshot holds no flag with that key it returns def, under the same rules as Enabled.

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

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

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

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

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

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

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) 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. Flags are a small, curated set, so the list is not paginated.

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

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.

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

func ActorFrom(ctx context.Context) (Ref, bool)

ActorFrom returns the actor stored by WithActor. The boolean is false when ctx carries none, in which case the Ref is zero.

func (Ref) IsZero added in v0.2.0

func (r Ref) IsZero() bool

IsZero reports whether both fields are empty.

func (Ref) String added in v0.2.0

func (r Ref) String() string

String returns "type:id", for logs and display only. It is not unique (a type may contain ':'), so never use it as a map, cache or database key; use the Ref itself or its two fields.

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.

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

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.

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

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

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