Files
multica/server/pkg/featureflag/service.go
Multica Eve 4a8210912a feat(featureflag): framework-level feature flag system (MUL-3615) (#4496)
* feat(featureflag): framework-level feature flag system (MUL-3615)

Introduces a reusable feature flag framework so future features can adopt
flags without writing infrastructure code.

Backend: server/pkg/featureflag (Go)
- Service / Provider / Decision separation per Martin Fowler's Toggle
  Point / Toggle Router / Toggle Configuration pattern.
- Providers: StaticProvider (rules in source control), EnvProvider
  (FF_<KEY> overrides for ops kill switches), ChainProvider
  (first-hit-wins composition).
- EvalContext carried through context.Context with WithEvalContext /
  EvalContextFrom; supports user_id, workspace_id, free-form attributes.
- PercentRollout via deterministic FNV-1a bucketing; same user always
  lands in the same bucket so experiments do not flap between requests.
- Nil-safe Service: a nil *Service or missing flag returns the caller's
  default so business code never panics on a missing flag.
- 100% unit-test coverage with -race; go vet clean.

Frontend: packages/core/feature-flags (TypeScript)
- Same vocabulary as the Go side (Decision, EvalContext, Rule,
  PercentRollout). FNV-1a parity ensures cross-tier bucket agreement.
- FeatureFlagService + StaticProvider + ChainProvider in pure TS.
- React glue: FeatureFlagsProvider, useFlag(key, default),
  useVariant(key, default). Hooks fall back to the default when no
  provider is mounted so Storybook / unit tests stay simple.
- Vitest tests for service, providers, hash, and React hooks.

Docs: docs/feature-flags.md — wiring, EvalContext, toggle points,
backend-protection note, and the standard best-practice checklist.

The framework intentionally has no third-party Go deps and no API
surface beyond what real callers will need. New providers (DB, remote
config, LaunchDarkly) plug in by implementing Provider; no existing
caller has to change.

Co-authored-by: multica-agent <github@multica.ai>

* fix(featureflag): cross-tier hash parity + variant only when enabled (MUL-3615)

Two must-fix issues from the PR review on #4496:

1. TS hash had a trailing zero separator that Go did not emit, so the
   same (key, identifier) bucketed differently on the two tiers. The
   "user lands in the same bucket on server and client" promise was
   broken. For example billing_new_invoice/user-42 was bucket 97 in Go
   and bucket 11 in TS.

   Fix: TS fnv1a now emits the zero separator BETWEEN parts only, never
   after the last one, matching Go's hash.Write byte stream exactly.
   Verified by parallel golden tests on both sides that pin five
   (key, identifier) -> bucket triples; if either side drifts both tests
   fail and one must be brought back in sync.

2. StaticProvider returned `Rule.Variant` regardless of whether the rule
   evaluated to enabled=true. A 0%-rollout user, a deny-listed user, or
   a default-off user would see variant="experiment-v2", so callers
   branching on Variant() would route control users into the experiment
   arm.

   Fix: Rule.Variant is now the ON-variant only. When the rule evaluates
   to enabled=false the Decision's variant is the canonical "off",
   regardless of what Rule.Variant says. Documented as a behavior
   contract in the Rule godoc / JSDoc and covered by regression tests
   on both sides.

Tests: - go test -race ./pkg/featureflag/...  : all green (1.58s).
  - pnpm --filter @multica/core test     : 661/661 (3 new).
  - pnpm --filter @multica/core typecheck: clean.
Co-authored-by: multica-agent <github@multica.ai>

* fix(featureflag): hash UTF-8 bytes on the TS side for cross-tier parity (MUL-3615)

Follow-up review on PR #4496 caught that the previous hash fix was only
correct for ASCII input. The TS side used `charCodeAt`, which returns
UTF-16 code units, while the Go side hashes the UTF-8 byte
representation. Any non-ASCII flag key or identifier — Chinese flag
names, accented user IDs, emoji — would bucket differently on backend
vs frontend, silently breaking the "same user, same bucket" promise the
PR description makes.

Concretely:
  flag/é         Go 53  vs TS-old 68
  flag/🦄        Go 82  vs TS-old 75
  实验/user-1    Go 90  vs TS-old 4
  flag/用户-1    Go 95  vs TS-old 2

Fix: replace per-char charCodeAt with a module-level `TextEncoder`
('utf-8') and hash each encoded byte. After the fix all four cases above
match Go exactly, and the existing ASCII cases continue to match.

The cross-language golden tables on both sides now include the 5 new
non-ASCII cases alongside the 5 ASCII cases, so any future regression
that swaps UTF-8 for charCodeAt (or vice versa) will fail loudly on
both Go and TS simultaneously.

TextEncoder is part of WHATWG Encoding and is available in every
evergreen browser, in Node 11+, and in Hermes (React Native) >= 0.74,
which covers every runtime that imports @multica/core/feature-flags.

Tests: - go test -race ./pkg/featureflag/...   : all green.
  - pnpm --filter @multica/core test      : 661/661.
  - pnpm --filter @multica/core typecheck : clean.
Co-authored-by: multica-agent <github@multica.ai>

* feat(featureflag): wire into main app config — YAML file + env override (MUL-3615)

Follow-up requested by Yushen on PR #4496: make the feature flag
framework configurable through the existing main-program config system
instead of requiring Go code edits. multica's main app is purely env-var
driven (see .env.example) with optional MULTICA_*_FILE knobs for richer
config; feature flags now follow the same pattern.

server/pkg/featureflag/config.go
  - LoadRulesFromYAMLFile(path) parses a YAML rule set into runtime
    Rule structs. Empty files are a valid "no flags yet" state; missing
    or malformed files surface a hard error so operators see misconfig
    the same way DATABASE_URL parse errors do.
  - NewServiceFromEnv composes the standard provider chain:
      1. EnvProvider("FF_")               (runtime kill-switch path)
      2. StaticProvider from YAML file    (declarative rule set)
    When MULTICA_FEATURE_FLAGS_FILE is unset, only the env layer is
    active and every IsEnabled call falls through to the caller's
    default, so the server can boot before any flag is authored.

server/cmd/server/main.go
  - Construct the Service once at startup right after env-var warnings,
    fail loudly on malformed YAML, log the loaded rule count via the
    Service logger. The Service is held in a local `flags` variable
    ready to be threaded into handler.Handler / service constructors
    when the first flag user lands. Threading is deferred to the PR
    that adds the first business consumer so this PR stays a pure
    framework + config layer.

.env.example
  - New "Feature flags" section documents MULTICA_FEATURE_FLAGS_FILE and
    the FF_<KEY> override convention, with a minimal YAML schema example
    inline.

docs/feature-flags.md
  - Replace the "build a provider manually" example with the
    NewServiceFromEnv pattern that now matches what main.go actually
    does. Show the YAML schema in one place. Note the on-variant /
    off semantics from the previous review round.

server/pkg/featureflag/doc.go
  - Update package doc to mention the gopkg.in/yaml.v3 dependency
    (already a server-level dep) instead of the now-inaccurate
    "no third-party dependencies" claim.

Tests: - go test -race -count=1 ./pkg/featureflag/...   all green; new
    config_test.go covers: simple YAML, full-shape YAML, empty file,
    missing file, malformed YAML, no env var, file-only, env-beats-file,
    bad file surfaces error.
  - go test -race -count=1 -run TestHealth ./cmd/server/...   sanity
    check that the main.go boot path with the new wiring still passes.
  - go vet ./...   clean.
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 13:49:59 +08:00

150 lines
4.9 KiB
Go

package featureflag
import (
"context"
"log/slog"
)
// Service is the framework-level Toggle Router. Business code asks the
// Service for flag decisions; the Service in turn consults its configured
// Provider. The Service is safe for concurrent use and is the only type
// callers should hold a reference to.
//
// A nil *Service is valid and behaves as if every flag were missing: every
// call returns the supplied default with Reason=ReasonDefault. This lets
// callers compose Service without first guarding against nil, which in
// practice is the most common cause of feature-flag-related nil panics.
type Service struct {
provider Provider
logger *slog.Logger
}
// Option configures optional Service behavior.
type Option func(*Service)
// WithLogger attaches a structured logger that the Service will use to emit
// warnings for malformed flag configuration. By default the Service is
// silent so it can be embedded in tests without polluting output.
func WithLogger(l *slog.Logger) Option {
return func(s *Service) {
if l != nil {
s.logger = l
}
}
}
// NewService returns a Service backed by the supplied provider. Passing a
// nil provider is allowed and is equivalent to the always-default behavior;
// see the package doc for the rationale.
func NewService(provider Provider, opts ...Option) *Service {
s := &Service{provider: provider}
for _, opt := range opts {
opt(s)
}
return s
}
// IsEnabled returns true when the named flag evaluates to an "on" state for
// the EvalContext attached to ctx. When the flag is unknown or its provider
// errors, the supplied default is returned so business code can ship with
// confidence that a missing flag never crashes a request.
//
// IsEnabled is the most common Toggle Point in business code:
//
// if flags.IsEnabled(ctx, "billing_new_invoice_email", false) {
// return s.sendNewInvoiceEmail(ctx, invoice)
// }
// return s.sendLegacyInvoiceEmail(ctx, invoice)
func (s *Service) IsEnabled(ctx context.Context, key string, defaultVal bool) bool {
return s.Decision(ctx, key, defaultVal).Enabled
}
// Variant returns the raw variant value for the named flag, falling back to
// defaultVal when no provider matches. Use Variant for multi-arm flags
// (A/B/C tests, "control"/"experiment"/"holdout"). For simple on/off flags,
// prefer IsEnabled.
func (s *Service) Variant(ctx context.Context, key string, defaultVal string) string {
d := s.decisionWithVariantDefault(ctx, key, defaultVal)
return d.Variant
}
// Decision returns the full structured Decision for a flag. The supplied
// boolean default is used to populate both Variant and Enabled when no
// provider matches the key. Diagnostic endpoints and tests use this entry
// point to surface Reason and Source.
func (s *Service) Decision(ctx context.Context, key string, defaultVal bool) Decision {
if s == nil || s.provider == nil {
return defaultDecision(key, boolToVariant(defaultVal), defaultVal)
}
d, ok := s.provider.Lookup(ctx, key)
if !ok {
return defaultDecision(key, boolToVariant(defaultVal), defaultVal)
}
if d.Reason == ReasonError && s.logger != nil {
s.logger.WarnContext(ctx, "feature flag provider returned an error decision",
slog.String("key", key),
slog.String("source", d.Source),
)
}
d.Key = key
return d
}
// decisionWithVariantDefault is the variant-aware twin of Decision. It is
// kept private because callers who care about reasons can rely on Decision
// + IsEnabled; Variant is a convenience.
func (s *Service) decisionWithVariantDefault(ctx context.Context, key, defaultVariant string) Decision {
if s == nil || s.provider == nil {
return defaultDecision(key, defaultVariant, variantEnabled(defaultVariant))
}
d, ok := s.provider.Lookup(ctx, key)
if !ok {
return defaultDecision(key, defaultVariant, variantEnabled(defaultVariant))
}
d.Key = key
return d
}
// Provider exposes the wrapped Provider so diagnostic endpoints can iterate
// known flags. Callers MUST NOT mutate the returned Provider; the contract
// is read-only.
func (s *Service) Provider() Provider {
if s == nil {
return nil
}
return s.provider
}
func defaultDecision(key, variant string, enabled bool) Decision {
return Decision{
Key: key,
Enabled: enabled,
Variant: variant,
Reason: ReasonDefault,
Source: "default",
}
}
// boolToVariant produces the canonical variant string for a boolean flag.
// "on" / "off" is used rather than "true" / "false" so that string-typed
// providers (e.g. env vars) do not collide with the user's own bool-as-text
// values.
func boolToVariant(b bool) string {
if b {
return "on"
}
return "off"
}
// variantEnabled reports whether a variant string projects to "enabled".
// Empty and "off" are the only false values; everything else, including
// arbitrary variant identifiers like "experiment-v2", is enabled. Callers
// who care about specific variants should compare with == directly.
func variantEnabled(v string) bool {
switch v {
case "", "off", "false", "0":
return false
}
return true
}