mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
* 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>
150 lines
4.9 KiB
Go
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
|
|
}
|