Files
multica/server/pkg/featureflag/eval_context.go
Eve 1fee14bd2c 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>
2026-06-24 13:11:39 +08:00

82 lines
2.5 KiB
Go

package featureflag
import "context"
// EvalContext is the per-request context used to evaluate dynamic flags such
// as percent rollouts and per-user allow/deny lists.
//
// All fields are optional. A zero EvalContext is valid and matches no
// targeting rules, which means percent rollouts default to bucket 0 (always
// off) and allow/deny lookups silently miss.
type EvalContext struct {
// UserID is the canonical identifier used for per-user targeting and
// for the default percent-rollout bucketing key. Free-form string;
// the framework never parses it.
UserID string
// WorkspaceID identifies the multica workspace that issued the
// request. Useful for workspace-scoped rollouts.
WorkspaceID string
// Attributes holds any other targeting attributes the caller wants
// to expose to rules, for example "country", "plan", or "client".
// Keys are case-sensitive.
Attributes map[string]string
}
// Lookup returns the value of attribute name in the order:
// UserID, WorkspaceID, then Attributes[name]. The well-known names
// "user_id" and "workspace_id" map to the dedicated fields so rules can use
// them by name without callers having to also populate Attributes.
//
// The bool return signals whether a non-empty value was found, which lets
// callers distinguish "missing" from "explicitly empty".
func (ec EvalContext) Lookup(name string) (string, bool) {
switch name {
case "user_id":
if ec.UserID != "" {
return ec.UserID, true
}
return "", false
case "workspace_id":
if ec.WorkspaceID != "" {
return ec.WorkspaceID, true
}
return "", false
}
if ec.Attributes == nil {
return "", false
}
v, ok := ec.Attributes[name]
if !ok || v == "" {
return "", false
}
return v, true
}
type evalContextKey struct{}
// WithEvalContext returns a derived context that carries ec for later
// retrieval via EvalContextFrom. Passing the zero EvalContext is allowed and
// effectively clears any previously attached context.
func WithEvalContext(parent context.Context, ec EvalContext) context.Context {
if parent == nil {
parent = context.Background()
}
return context.WithValue(parent, evalContextKey{}, ec)
}
// EvalContextFrom extracts the EvalContext previously attached with
// WithEvalContext. It returns the zero value when the context carries no
// EvalContext, never nil, so callers can read fields unconditionally.
func EvalContextFrom(ctx context.Context) EvalContext {
if ctx == nil {
return EvalContext{}
}
v, ok := ctx.Value(evalContextKey{}).(EvalContext)
if !ok {
return EvalContext{}
}
return v
}