mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 22:09:44 +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>
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
"use client";
|
|
|
|
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
import type { EvalContext } from "./types";
|
|
import { FeatureFlagService } from "./service";
|
|
|
|
/**
|
|
* React glue for the FeatureFlagService.
|
|
*
|
|
* Two pieces are exported:
|
|
*
|
|
* - {@link FeatureFlagsProvider}: wraps a part of the tree with a Service
|
|
* and an EvalContext. The Service is usually constructed once at the
|
|
* application root; the EvalContext changes as the user context changes
|
|
* (e.g. after login).
|
|
* - {@link useFlag} / {@link useVariant}: the recommended Toggle Points in
|
|
* UI code. They never throw; if the provider tree is missing they fall
|
|
* back to the supplied default, which keeps Storybook stories and unit
|
|
* tests from needing to mount the provider just to render a button.
|
|
*
|
|
* Note: we deliberately do NOT expose the underlying FeatureFlagService
|
|
* through hooks. Components that need raw access can read it via the
|
|
* exported context object, but at the cost of giving up the always-on
|
|
* safety guarantee.
|
|
*/
|
|
|
|
interface FeatureFlagContextValue {
|
|
service: FeatureFlagService;
|
|
ctx: EvalContext;
|
|
}
|
|
|
|
const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
|
|
|
|
export interface FeatureFlagsProviderProps {
|
|
service: FeatureFlagService;
|
|
/**
|
|
* Targeting context for every flag evaluation inside this subtree.
|
|
* Pass an empty object when the user is anonymous — percent rollouts
|
|
* and allow/deny lists then evaluate against the empty identifier,
|
|
* which is the desired behavior for anonymous traffic.
|
|
*/
|
|
context?: EvalContext;
|
|
children: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Mount a FeatureFlagService and EvalContext into the tree. Replacing the
|
|
* `service` prop on a re-render is allowed but rare; prefer mutating the
|
|
* provider on the existing Service via `setProvider`, which avoids forcing
|
|
* every consumer to re-evaluate.
|
|
*/
|
|
export function FeatureFlagsProvider({
|
|
service,
|
|
context: ctx = {},
|
|
children,
|
|
}: FeatureFlagsProviderProps) {
|
|
const value = useMemo<FeatureFlagContextValue>(
|
|
() => ({ service, ctx }),
|
|
[service, ctx],
|
|
);
|
|
return (
|
|
<FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* useFlag returns the boolean state of a feature flag.
|
|
*
|
|
* Outside a {@link FeatureFlagsProvider} the hook returns `defaultValue`,
|
|
* never throws. This keeps tests and stories independent of the provider.
|
|
*
|
|
* @example
|
|
* const showNewBilling = useFlag("billing_v2_dashboard", false);
|
|
* return showNewBilling ? <BillingV2 /> : <BillingV1 />;
|
|
*/
|
|
export function useFlag(key: string, defaultValue: boolean): boolean {
|
|
const value = useContext(FeatureFlagContext);
|
|
if (!value) return defaultValue;
|
|
return value.service.isEnabled(key, value.ctx, defaultValue);
|
|
}
|
|
|
|
/**
|
|
* useVariant returns the raw variant identifier for a multi-arm flag, with
|
|
* the same out-of-provider safety as {@link useFlag}.
|
|
*
|
|
* @example
|
|
* const variant = useVariant("checkout_algo", "control");
|
|
* switch (variant) {
|
|
* case "experiment-v2": return <CheckoutV2 />;
|
|
* case "experiment-v3": return <CheckoutV3 />;
|
|
* default: return <CheckoutControl />;
|
|
* }
|
|
*/
|
|
export function useVariant(key: string, defaultValue: string): string {
|
|
const value = useContext(FeatureFlagContext);
|
|
if (!value) return defaultValue;
|
|
return value.service.variant(key, value.ctx, defaultValue);
|
|
}
|
|
|
|
/**
|
|
* Escape hatch for diagnostic overlays that need direct Service access.
|
|
* Returns `null` outside a provider so callers must guard explicitly —
|
|
* this is intentional: random component code should use {@link useFlag},
|
|
* not the raw Service.
|
|
*/
|
|
export function useFeatureFlagService(): FeatureFlagService | null {
|
|
return useContext(FeatureFlagContext)?.service ?? null;
|
|
}
|