flagpole

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jun 16, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

README

flagpole

A lightweight Go feature-flag library with a GrowthBook-compatible local evaluator.

Flags are evaluated in-process — no per-flag network call and no external service in the hot path. Feature definitions use the same JSON schema as GrowthBook, and bucketing uses the same FNV-1a v2 hash, so a flag written for flagpole ports to GrowthBook (and back) unchanged, and the same users stay in the same cohorts if you ever switch.

c, _ := flagpole.New(ctx, src)
defer c.Close()

if c.For(flagpole.Attributes{"id": userID, "plan": "pro"}).IsOn("new-checkout") {
    // ship the new thing to this user
}

Contents


Why flagpole

  • Local evaluation. The client loads all flag definitions once and refreshes them on an interval; every IsOn/Value call is a pure in-memory computation. Nothing flagpole-shaped ever sits in your request or job hot path.
  • Deterministic, consistent bucketing. Percentage rollouts hash hash(seed + attribute) with GrowthBook's exact FNV-1a v2 algorithm. The same identifier always lands in the same bucket — across processes, across restarts, and across a migration to or from GrowthBook.
  • No new infrastructure required. Bring any Source (a static JSON payload, your own loader, or the included Postgres adapter). No daemon, no sidecar.
  • Batteries included. A Postgres-backed source (sourcepg) and a mountable admin CRUD handler (adminhttp) ship in the box, while the core package stays dependency-free.
  • Honest about its scope. flagpole implements a strict, tested subset of GrowthBook. Anything outside that subset is skipped, never silently mis-evaluated, and compatibility is verified against GrowthBook's own published test fixtures.

The core package (flagpole) has zero third-party dependencies. The sourcepg adapter pulls in pgx; the adminhttp handler uses only the standard library.

Install

go get github.com/sudarkoff/flagpole

Requires Go 1.25+.

Quick start

Static source (testing, or a payload fetched out of band)
import "github.com/sudarkoff/flagpole"

payload := []byte(`{
  "features": {
    "dark-mode": {
      "defaultValue": false,
      "rules": [
        {"condition": {"plan": "pro"}, "force": true}
      ]
    }
  }
}`)

src, err := flagpole.StaticSourceFromJSON(payload)
if err != nil {
    log.Fatal(err)
}

c, err := flagpole.New(ctx, src)
if err != nil {
    log.Fatal(err)
}
defer c.Close()

attrs := flagpole.Attributes{"id": "user-123", "plan": "pro"}
if c.For(attrs).IsOn("dark-mode") {
    // flag is on for this user
}
Postgres source
import (
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/sudarkoff/flagpole"
    "github.com/sudarkoff/flagpole/sourcepg"
)

pool, _ := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))

c, err := flagpole.New(ctx, sourcepg.New(pool),
    flagpole.WithRefreshInterval(30*time.Second),
)
if err != nil {
    log.Fatal(err)
}
defer c.Close()

ev := c.For(flagpole.Attributes{"id": userID, "plan": plan})
if ev.IsOn("new-checkout") {
    // ...
}
color := ev.Value("button-color", "blue") // "blue" if the flag is unknown

Concepts

Attributes

Attributes is a flat map[string]any describing the unit you're evaluating for (usually a user). Keys are arbitrary and are referenced by your flag definitions' hashAttribute and condition fields. The default hash attribute used for percentage rollouts (when a rule doesn't set hashAttribute) is "id".

attrs := flagpole.Attributes{
    "id":     "user-abc",   // default bucketing key
    "plan":   "pro",
    "country": "US",
    "beta":   true,
    "roles":  []any{"admin", "billing"},
}

Numbers from JSON arrive as float64; flagpole compares numbers numerically so an int attribute still matches a JSON number in a condition.

Feature & Rule

A Feature is one flag: a defaultValue plus an ordered list of rules. Rules are evaluated top to bottom and the first matching rule wins; if none match, defaultValue is returned.

type Feature struct {
    DefaultValue any
    Rules        []Rule
}

A Rule can force a value, gate on a condition, and/or apply a percentage rollout. The fields flagpole evaluates:

Field JSON Meaning
Condition condition Targeting object (subset of GrowthBook conditions). Rule applies only if it matches.
Force force The value to return when the rule applies. If omitted, defaultValue is used.
Coverage coverage Percentage rollout in [0,1]. The rule applies only if the hashed unit falls under this fraction.
HashAttribute hashAttribute Attribute used for rollout bucketing (default "id").
Seed seed Rollout hash seed (default: the feature key). Change it to re-randomize a rollout independently.
HashVersion hashVersion Must be 2 (or omitted). A rule requesting any other version is skipped.

The JSON shape is a strict subset of GrowthBook's feature schema, so the same definitions load into GrowthBook unchanged.

Targeting & rollout

Conditions

A condition is an object of attribute → match. All entries are AND-ed.

// implicit equality
{"plan": "pro"}

// operators
{"plan": {"$in": ["pro", "team"]}}
{"plan": {"$ne": "free"}}
{"country": {"$eq": "US"}}

// multiple fields (AND)
{"plan": "pro", "country": "US"}

Supported operators: implicit equality (including array/object deep-equality), $eq, $ne, and $in. $in matches by set intersection when the attribute itself is an array — e.g. {"roles": {"$in": ["admin"]}} matches roles: ["billing", "admin"].

Any condition using an operator outside this set — including top-level logical operators like $or/$and/$not/$nor — causes the rule to be skipped (evaluation falls through to the next rule), never silently mis-evaluated.

Percentage rollout
{
  "defaultValue": false,
  "rules": [
    { "condition": {"plan": "pro"}, "coverage": 0.25, "force": true }
  ]
}

This turns the flag on for a deterministic 25% of pro users. Bucketing is hash(seed + attributes[hashAttribute]) using FNV-1a v2; a given id always lands in the same place, so ramping coverage from 0.250.50 only adds users — nobody who was on gets turned off. Set seed to roll an independent dice for a different rollout.

A rule can combine all three concerns — a condition (who's eligible), a coverage (what fraction of them), and a force value (what they get).

Evaluation API

c.For(attrs) returns an *Evaluation bound to those attributes:

ev := c.For(flagpole.Attributes{"id": userID, "plan": plan})

on   := ev.IsOn("new-checkout")              // bool; unknown flag → false
color := ev.Value("button-color", "blue")    // any; unknown/nil → "blue"
  • IsOn(key) bool — true if the flag resolves to a truthy value (false, 0, "", "false", "0", and nil are falsy). Unknown flags are off.
  • Value(key string, def any) any — the resolved value, or def if the flag is unknown or resolves to nil.

For one-off evaluation without a Client, the pure function flagpole.Evaluate(feature, key, attrs) Result is also exported (Result{Value any; On bool}).

Sources

A Source supplies the full set of feature definitions. The Client loads it on startup and on each refresh.

type Source interface {
    Load(ctx context.Context) (map[string]Feature, error)
}
StaticSource / StaticSourceFromJSON

StaticSource{Features: ...} serves a fixed map — handy for tests or when you fetch and parse the payload yourself. StaticSourceFromJSON([]byte) parses a GrowthBook-style {"features": {...}} envelope.

Don't mutate StaticSource.Features after handing it to a Client — Load returns the map directly, so a later mutation would bypass the Client's lock.

sourcepg — Postgres-backed source

sourcepg.New(pool *pgxpool.Pool) *sourcepg.Source returns a flagpole.Source backed by a feature_flags table. Only archived = false rows are loaded. Run the reference schema (sourcepg/schema.sql) with your own migration tooling:

CREATE TABLE IF NOT EXISTS feature_flags (
    key         TEXT PRIMARY KEY,
    description TEXT NOT NULL DEFAULT '',
    definition  JSONB NOT NULL,          -- the Feature JSON (GrowthBook-subset shape)
    archived    BOOLEAN NOT NULL DEFAULT FALSE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Custom sources

Implement Load over anything — an HTTP endpoint serving a GrowthBook-format payload, a config file, a key-value store. The Client treats every source identically.

The Client

flagpole.New(ctx, src, opts...) loads features synchronously once (so a Source error surfaces immediately) and then refreshes them in the background until you call Close(). The Client is safe for concurrent use.

c, err := flagpole.New(ctx, src,
    flagpole.WithRefreshInterval(30*time.Second),
    flagpole.WithTracker(myTracker),
)
Option Description
WithRefreshInterval(d) How often to reload from the Source (default 60s; ≤ 0 loads once and never refreshes).
WithTracker(tr) Experiment exposure tracker (default NoopTracker).

Close() stops the background refresh and is safe to call once the Client is no longer needed (and safe to call more than once).

Because evaluation is local and bucketing is deterministic, multiple processes pointed at the same Source (e.g. an API server and a background worker sharing one Postgres) will independently agree on the result for any given user — no coordination required.

Admin HTTP handler

adminhttp.NewHandler(store adminhttp.Store) http.Handler is a mountable JSON CRUD handler for managing flag definitions at runtime. It is auth-agnostic — wrap it with your own authentication/authorization before mounting.

Method Path Description
GET /flags List all feature definitions ({key: Feature}).
PUT /flags/{key} Upsert a feature (body: Feature JSON).
DELETE /flags/{key} Archive a feature.
mux := http.NewServeMux()
admin := adminhttp.NewHandler(myStore)
mux.Handle("/admin/flags", requireAdmin(http.StripPrefix("/admin", admin)))
mux.Handle("/admin/flags/", requireAdmin(http.StripPrefix("/admin", admin)))

Implement the Store interface against your persistence layer:

type Store interface {
    List(ctx context.Context) (map[string]flagpole.Feature, error)
    Upsert(ctx context.Context, key string, f flagpole.Feature) error
    Archive(ctx context.Context, key string) error // idempotent
}

A minimal Postgres Store over the same feature_flags table:

type pgStore struct{ pool *pgxpool.Pool }

func (s pgStore) List(ctx context.Context) (map[string]flagpole.Feature, error) {
    return sourcepg.New(s.pool).Load(ctx) // or your own query incl. archived rows
}
func (s pgStore) Upsert(ctx context.Context, key string, f flagpole.Feature) error {
    def, _ := json.Marshal(f)
    _, err := s.pool.Exec(ctx, `
        INSERT INTO feature_flags (key, definition) VALUES ($1, $2)
        ON CONFLICT (key) DO UPDATE SET definition = EXCLUDED.definition, updated_at = NOW()`,
        key, def)
    return err
}
func (s pgStore) Archive(ctx context.Context, key string) error {
    _, err := s.pool.Exec(ctx,
        `UPDATE feature_flags SET archived = true, updated_at = NOW() WHERE key = $1`, key)
    return err
}

Experiment exposure tracking

flagpole.Tracker is the seam for logging experiment exposures:

type Tracker interface {
    Track(ctx context.Context, e Exposure)
}

type Exposure struct {
    ExperimentKey string
    VariationID   int
    Attributes    Attributes
    At            time.Time
}

The default is NoopTracker (discards exposures). Provide one with WithTracker(tr) to feed your own analytics pipeline. The Exposure shape mirrors GrowthBook's exposure logging, so downstream analysis can be done by GrowthBook's warehouse-native tooling or by your own SQL. Exposure analysis (metrics, significance) is not part of this release — see the roadmap.

GrowthBook compatibility

flagpole implements a strict, tested subset of the GrowthBook feature evaluation algorithm.

Supported:

  • defaultValue / force values
  • Percentage rollout via coverage (deterministic FNV-1a v2 bucketing, hashVersion: 2)
  • hashAttribute and seed on rollout rules
  • Condition operators: equality (implicit, incl. array/object deep-equality), $eq, $ne, $in (set-intersection when the attribute is an array)

Out of scope (skipped, not evaluated):

  • Experiment variations / weights / key (Phase B)
  • Condition operators: $gt, $gte, $lt, $lte, $regex, $exists, $not, $or, $and, $nor, and others
  • range, filters, parentConditions
  • hashVersion other than 2

A condition using an unsupported operator — including top-level $or/$and/$not/$nor — causes its rule to be skipped, never silently mis-evaluated.

Migrating from GrowthBook: flagpole buckets with hashVersion: 2. A rollout rule with no hashVersion is bucketed with v2 here, whereas GrowthBook's default is v1 — so set hashVersion: 2 explicitly on any rollout rule you intend to share between the two systems. A rule requesting any other version is skipped.

Compatibility is validated against GrowthBook's published cases.json SDK test fixtures (compat_test.go): the hashing vectors, the feature-evaluation suite, and the evalCondition oracle for the supported operator subset. Unsupported fixtures are explicitly skipped rather than silently passed.

How it works

  1. Definitions live wherever your Source reads them (Postgres, JSON, etc.) in GrowthBook-subset shape.
  2. On New, the Client loads all definitions into an in-memory snapshot and starts a refresh ticker. Each refresh swaps the whole snapshot under a write lock; readers take a read lock, so they always see a consistent set.
  3. For(attrs).IsOn(key) looks up the feature and runs the pure evaluator: walk rules in order, check each rule's condition and coverage, return the first match (or the default).
  4. Coverage hashes seed + attributes[hashAttribute] with FNV-1a v2 into [0,1) and compares against the rule's coverage. This is the single source of bucketing determinism and the thing that makes flagpole a drop-in for GrowthBook.

There is no network call during evaluation, and the evaluator is allocation-light and lock-cheap (a single read-lock per lookup).

Testing

go test ./...                 # core + adminhttp (sourcepg skips without a DB)
go test -race ./...           # with the race detector

# exercise the Postgres adapter against a real database:
export FLAGPOLE_TEST_DATABASE_URL='postgres://user:pass@localhost:5432/flagpole_test?sslmode=disable'
go test ./sourcepg/...

The compatibility suite (compat_test.go) runs flagpole's hashing and evaluator against GrowthBook's vendored cases.json fixtures.

Roadmap

  • @flagpole/react — a React provider + useFeatureIsOn/useFeatureValue hooks, hydrated from a server-evaluated payload (no flag flicker, no browser-side flag fetch).
  • Phase B — experiments — exposure analysis: metric definitions, lift, and significance over the exposures captured by Tracker.

The Tracker/Exposure seam and the experiment fields in the schema are already in place so these are additive.

License

Apache-2.0. See LICENSE.

Documentation

Overview

Package flagpole evaluates feature flags locally using a GrowthBook-compatible algorithm: the same FNV-1a v2 hashing and a strict subset of GrowthBook's feature schema, so definitions and bucketing port to GrowthBook unchanged.

Typical use:

c, _ := flagpole.New(ctx, src) // src is a Source (e.g. sourcepg.New(pool))
defer c.Close()
if c.For(flagpole.Attributes{"id": userID, "plan": plan}).IsOn("my-flag") {
    // ...
}

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Attributes

type Attributes map[string]any

Attributes is the flat bag of values a flag is evaluated against. Identical in spirit to GrowthBook's attribute model.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client caches feature definitions from a Source and evaluates them locally. It is safe for concurrent use.

func New

func New(ctx context.Context, src Source, opts ...Option) (*Client, error)

New loads features once synchronously, then (unless disabled) refreshes them on an interval until Close is called.

func (*Client) Close

func (c *Client) Close()

Close stops background refresh.

func (*Client) For

func (c *Client) For(attrs Attributes) *Evaluation

For binds attributes for evaluation.

type Evaluation

type Evaluation struct {
	// contains filtered or unexported fields
}

Evaluation evaluates flags for a fixed set of attributes.

func (*Evaluation) IsOn

func (e *Evaluation) IsOn(key string) bool

IsOn reports whether the flag resolves to a truthy value. Unknown flags are off.

func (*Evaluation) Value

func (e *Evaluation) Value(key string, def any) any

Value returns the flag's resolved value, or def if the flag is unknown or resolves to nil. An unknown flag yields a nil Result value, so a single evaluation covers both cases.

type Exposure

type Exposure struct {
	ExperimentKey string
	VariationID   int
	Attributes    Attributes
	At            time.Time
}

Exposure records that a unit was exposed to a variation of an experiment. Field shape mirrors GrowthBook exposure logging so downstream analysis can be done by GrowthBook or by hand-written SQL.

type Feature

type Feature struct {
	DefaultValue any    `json:"defaultValue"`
	Rules        []Rule `json:"rules,omitempty"`
}

Feature is a single flag definition. Its JSON shape is a strict subset of GrowthBook's feature schema, so definitions port to GrowthBook unchanged.

type NoopTracker

type NoopTracker struct{}

NoopTracker discards exposures.

func (NoopTracker) Track

type Option

type Option func(*Client)

Option configures a Client.

func WithRefreshInterval

func WithRefreshInterval(d time.Duration) Option

WithRefreshInterval sets how often the Client reloads from its Source. Zero or negative disables background refresh (load once).

func WithTracker

func WithTracker(tr Tracker) Option

WithTracker sets the experiment exposure tracker (default: NoopTracker).

type Result

type Result struct {
	Value any  // resolved value (force value or defaultValue)
	On    bool // truthiness of Value
}

Result is the outcome of evaluating a feature.

func Evaluate

func Evaluate(f Feature, featureKey string, attrs Attributes) Result

Evaluate resolves a feature for the given attributes. Rules are tried in order; the first one that fully matches wins. featureKey is used as the default rollout seed (matching GrowthBook).

type Rule

type Rule struct {
	// Targeting: a GrowthBook-style condition object (subset supported).
	Condition map[string]any `json:"condition,omitempty"`

	// Forced value when the rule applies.
	Force any `json:"force,omitempty"`

	// Percentage rollout in [0,1].
	Coverage      *float64 `json:"coverage,omitempty"`
	HashAttribute string   `json:"hashAttribute,omitempty"`
	Seed          string   `json:"seed,omitempty"`
	HashVersion   *int     `json:"hashVersion,omitempty"`

	// Experiment fields (Phase B). Present in the schema now; evaluation of
	// experiment rules is not implemented in this plan.
	Key        string    `json:"key,omitempty"`
	Variations []any     `json:"variations,omitempty"`
	Weights    []float64 `json:"weights,omitempty"`

	// Advanced bucketing fields (Phase B+). Parsed but not evaluated; presence
	// triggers the unsupported-case skip in the compatibility test suite.
	Range   []float64        `json:"range,omitempty"`
	Filters []map[string]any `json:"filters,omitempty"`

	// Prerequisite flags (Phase B+). Parsed but not evaluated.
	ParentConditions []map[string]any `json:"parentConditions,omitempty"`
}

Rule is one targeting/rollout/experiment rule, evaluated in order.

type Source

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

Source supplies the full set of feature definitions. Implementations are expected to be cheap to call repeatedly; the Client caches results.

type StaticSource

type StaticSource struct {
	Features map[string]Feature
}

StaticSource serves a fixed set of features (tests, or a GrowthBook-format payload fetched elsewhere).

Do not mutate Features after passing the StaticSource to a Client: Load returns the map directly, so a later mutation would be observed by the Client outside its lock.

func StaticSourceFromJSON

func StaticSourceFromJSON(b []byte) (StaticSource, error)

StaticSourceFromJSON parses a GrowthBook-style `{"features": {...}}` payload.

func (StaticSource) Load

type Tracker

type Tracker interface {
	Track(ctx context.Context, e Exposure)
}

Tracker records experiment exposures. Phase A ships only the no-op; consumers supply a persistent implementation for Phase B.

Directories

Path Synopsis
Package adminhttp exposes a mountable, auth-agnostic JSON CRUD handler for managing flagpole feature definitions.
Package adminhttp exposes a mountable, auth-agnostic JSON CRUD handler for managing flagpole feature definitions.
Package sourcepg provides a Postgres-backed flagpole.Source over a feature_flags table.
Package sourcepg provides a Postgres-backed flagpole.Source over a feature_flags table.

Jump to

Keyboard shortcuts

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