flag

package
v0.27.0 Latest Latest
Warning

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

Go to latest
Published: Aug 4, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package flag is feature flags for smolanalytics — boolean and multivariate, with property targeting and percentage rollouts, evaluated deterministically so the same user always lands in the same bucket. What makes it deeper than a plain flag console (a later increment): a flag flip is auto-recorded as a deploy marker, so the existing deploy-impact engine answers "did flag X move activation?" from your editor, provably. This file is the pure engine — types + evaluation — with no I/O, so it's trivially testable and shared verbatim with any SDK that copies the bucketing.

Index

Constants

View Source
const (
	ExposureEvent = "$feature_flag_called"
	PropFlag      = "$feature_flag"
	PropVariant   = "$feature_flag_response"
)

Exposure/response property keys, mirroring PostHog's $feature_flag_called convention. The "$" prefix means the tracking-plan drift gate treats these as system events, not unplanned ones.

View Source
const SRMAlpha = 0.001

Sample Ratio Mismatch: the users actually exposed to each arm do not match the split that was configured. A 50/50 experiment that delivered 5,200 / 4,800 did not randomize the way it said it did, and once that is true nothing downstream can be trusted — the arms differ by whatever mechanism broke the split, not by the change being tested.

This is the single most valuable check an experiment tool can run, because without it a broken experiment is indistinguishable from a successful one. It reports a confident number either way. Every serious platform runs it; the difference between them and a homegrown A/B feature is usually this test and nothing else.

The threshold is deliberately far stricter than the usual 0.05. Experiments are checked constantly and by many people, so a 1-in-20 false-alarm rate would cry wolf until the warning is ignored — which is worse than not having it. GrowthBook fires at 0.001 and Microsoft at 0.0005; 0.001 matches the former.

Variables

This section is empty.

Functions

This section is empty.

Types

type Flag

type Flag struct {
	Key         string    `json:"key"`
	Description string    `json:"description,omitempty"`
	Enabled     bool      `json:"enabled"`
	Variants    []Variant `json:"variants,omitempty"`
	Rules       []Rule    `json:"rules,omitempty"`
	Measured    bool      `json:"measured,omitempty"`
	Created     time.Time `json:"created"`
	Updated     time.Time `json:"updated"`
}

Flag is a saved feature flag. Variants empty = a boolean flag (served variant is "on"). Rules empty = on for everyone when Enabled. Measured opts this flag into exposure logging (a later increment) so it can be A/B-analysed without every flag inflating the event count.

func (Flag) Evaluate

func (f Flag) Evaluate(distinctID string, context map[string]any) (string, bool)

Evaluate resolves the flag for one user, given their context properties. Returns the served variant ("on" for a boolean flag, "" when off) and whether the flag is on. Deterministic: the same key + distinct_id always yields the same result, computed only from a stable hash — no randomness, no state — so a client SDK that copies this bucketing agrees byte-for-byte.

type Interval added in v0.24.0

type Interval struct {
	Point float64 `json:"point"`
	Lo    float64 `json:"lo"`
	Hi    float64 `json:"hi"`
}

Interval is a range with the point estimate that generated it. Percentages, not proportions, because every other number on the report is a percentage and mixing the two is how a reader misreads by 100x.

type Report added in v0.9.7

type Report struct {
	Flag     string          `json:"flag"`
	Goal     string          `json:"goal"`
	Days     int             `json:"days"`
	Control  string          `json:"control"`
	Variants []VariantResult `json:"variants"`
	Note     string          `json:"note"`
}

Report is the A/B read for one measured flag: for each variant, how many exposed users converted on the goal event AFTER their first exposure, and whether the lift over the control arm is statistically significant. Pure + deterministic (same events → same report), so it is pinnable MCP==API by an agreement test, the same contract as every other report.

func Measure added in v0.9.7

func Measure(evs []event.Event, flagKey, goal string, days int) Report

Measure computes the report from raw events. An exposure is a $feature_flag_called event tagging the user's variant for this flag; a conversion is the user doing `goal` at or after their first exposure (so we never credit behavior that predates the experiment). Only events within the last `days` (0 = all) are considered.

type Rule

type Rule struct {
	Filters    []query.Filter `json:"filters,omitempty"`
	RolloutPct int            `json:"rollout_pct"`
}

Rule is one ordered targeting clause: the user's context must pass all Filters (empty = every user), and RolloutPct (0..100) is the deterministic share of matched users served. Rules are evaluated in order, first match wins.

type SRMResult added in v0.23.0

type SRMResult struct {
	Checked   bool               `json:"checked"`
	Detected  bool               `json:"detected"`
	PValue    float64            `json:"p_value"`
	ChiSquare float64            `json:"chi_square"`
	Observed  map[string]int     `json:"observed"`
	Expected  map[string]float64 `json:"expected"`
	Total     int                `json:"total"`
	// Culprit names the segment whose split is most skewed, when one stands out. This is the
	// difference between "your experiment is broken" and "your experiment is broken, and it is
	// iOS users: 340 in control against 91 in test".
	Culprit string `json:"culprit,omitempty"`
	Verdict string `json:"verdict"`
}

SRMResult is the health check for one experiment's traffic split.

func CheckSRM added in v0.23.0

func CheckSRM(evs []event.Event, f Flag, days int) SRMResult

CheckSRM compares how many users were actually exposed to each arm against the flag's configured weights.

Only arms the flag actually declares are counted. An exposure tagged with a variant that no longer exists is itself a problem, but it is a different one — folding it into the chi-square would blame the split for a stale SDK.

type Store

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

Store persists flags to a JSON file (atomic tmp+rename), same discipline as the cohort and deploy stores. Flags are keyed by their stable Key (e.g. "checkout_v2"), so Save is an upsert: creating or updating the flag with that key. Empty path = in-memory only.

func Open

func Open(path string) (*Store, error)

func (*Store) Delete

func (s *Store) Delete(key string) (found bool, err error)

Delete removes a flag by key. found is true only when a flag actually went away, so callers can report "nothing was deleted" instead of implying a removal that never happened. Deleting a key that isn't there is not an error (retries are fine).

func (*Store) Get

func (s *Store) Get(key string) (Flag, bool)

func (*Store) List

func (s *Store) List() []Flag

func (*Store) Save

func (s *Store) Save(f Flag) (Flag, error)

Save upserts by Key. A new key is created (Created stamped); an existing key is updated in place (Created preserved, Updated bumped). Validates the key and the variant weights.

func (*Store) SetEnabled

func (s *Store) SetEnabled(key string, on bool) (Flag, error)

SetEnabled toggles a flag on/off by key (the common flip). Returns the updated flag. A future increment records this flip as a deploy marker so its impact is measured automatically.

type Variant

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

Variant is one arm of a multivariate flag; Weight is its relative share (need not sum to 100).

type VariantResult added in v0.9.7

type VariantResult struct {
	Key       string `json:"key"`
	Exposed   int    `json:"exposed"`
	Converted int    `json:"converted"`
	// RatePct is the point estimate; RateCI is where the true rate plausibly sits. Wilson
	// interval, so it never reports a bound below 0% or above 100% on a small arm.
	RatePct float64  `json:"rate_pct"`
	RateCI  Interval `json:"rate_ci"`
	// DeltaPct is relative lift vs control (0 for control). DeltaCI is its interval, present
	// only when the control rate is far enough from zero for a ratio to be meaningful —
	// otherwise a handful of conversions prints "+4000%" and someone ships on it.
	DeltaPct    float64   `json:"delta_pct"`
	DeltaCI     *Interval `json:"delta_ci,omitempty"`
	PValue      float64   `json:"p_value,omitempty"`
	Significant bool      `json:"significant"`  // 95% two-proportion z-test vs control
	SmallSample bool      `json:"small_sample"` // too few exposed to trust the rate
	// Read is the sentence a person should act on. "Significant" invites shipping; "the range
	// still spans zero" invites waiting, which is usually the right call and one a boolean
	// never prompts.
	Read string `json:"read,omitempty"`
}

VariantResult is one arm of a measured flag's A/B read.

Every rate ships with the raw numerator and denominator that produced it and a 95% interval around it. A bare percentage asks to be trusted; 43 of 512, somewhere between 6.3% and 11.1%, can be checked. That is the difference this product claims to sell.

Jump to

Keyboard shortcuts

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