In a nutshell
Imagine every light in your house wired back to one universal dimmer panel by the front door. You can dim the kitchen to 20%, flip the garage off from across the room, and — crucially — swap the whole panel for a fancier model later without rewiring a single bulb, because every light just speaks “dimmer,” not “this exact brand of switch.” A feature flag is that dimmer for a piece of software: a runtime switch that decides whether a given user sees a new feature, with no code deploy needed to flip it.
OpenFeature is the universal wiring standard. It is a vendor-neutral specification (a CNCF Incubating project) that gives your code one small, stable way to ask “is this feature on for this user?” — and hides which flag service actually answers. Your application calls getBooleanValue('new-checkout', false, user) and never learns whether the reply came from an open-source daemon you run yourself or a paid SaaS. Swap the backend and not one line of feature code changes. flagd is OpenFeature’s own free, self-hostable answer-engine (the “flag daemon”); this lesson builds a full platform on it.
Why a beginner should care: the moment you separate deploying code from releasing a feature, a whole category of scary Friday-night launches disappears. You ship the code dark (off for everyone), then turn it on for 1% of users, watch the graphs, widen to 10%, 50%, 100% — and if it misbehaves, you flip one switch and it is off in seconds, no rollback deploy. That is progressive delivery, and feature flags are the engine that drives it.
Level: Advanced · Time: ~28 min
Before this lesson, it helps to know: the basic Kubernetes objects (Deployment, Service, ConfigMap), that an SDK is just a library your code imports, and roughly what a JSON config file is. If deployment strategies are new, skim Deployment strategies: rolling, blue-green, canary, flags first; the flag-driven release model here is the natural next step after Trunk-based development with feature flags.
After this lesson you will be able to:
- Explain the four moving parts of OpenFeature (API, provider, hooks, context) and why only the API touches your code.
- Deploy flagd on Kubernetes and serve flag evaluations over gRPC and the OFREP HTTP protocol.
- Author targeting rules with stable, independent percentage bucketing using the
fractionaloperator. - Wire the SDK so targeting context flows through a request without threading a user object into every function.
- Add telemetry, audit, and tracing to every flag evaluation with a single global hook.
- Swap the entire flag backend — flagd to LaunchDarkly and back — with a one-line change, and migrate one domain at a time.
- Govern flag debt so temporary release flags expire on schedule instead of accumulating forever.
Read it left to right: your app calls the vendor-neutral evaluation API, the OpenFeature SDK runs hooks and hands off to whichever provider is wired in, flagd (on Kubernetes) resolves the flag in-memory from a GitOps-managed flags.json, and the fractional rule buckets the user into a stable percentage variant — with a kill switch that flips the flag back to off on the next hot-reload, no deploy.
Most feature-flag adoptions start as a SaaS line item and end as a lock-in problem. The SDK is proprietary, evaluation semantics are undocumented, and three years later you have 400 call sites that all import LDClient. Migrating means a rewrite. OpenFeature breaks that coupling: it is a CNCF Incubating specification that standardizes the evaluation API so application code never names a vendor, and providers plug in underneath. Pair it with flagd – the project’s own reference flag daemon – and you get a fully open stack you can run yourself, with a clean migration path to a managed provider if you ever want one.
This article builds that platform end to end: the spec, the flagd deployment topology, real targeting rules with fractional rollouts, SDK wiring with context propagation, hooks for telemetry and audit, a live provider swap, flag governance, and deterministic CI testing.
1. The OpenFeature spec: API, providers, hooks, context
OpenFeature defines four moving parts, and the whole value proposition is that your code only touches the first.
- Evaluation API – the typed surface your code calls:
getBooleanValue,getStringValue,getNumberValue,getObjectValue, plus*Detailsvariants that also return the resolution reason, variant, and any error code. This is stable and vendor-free. - Provider – the adapter that actually resolves a flag. You register exactly one default provider (plus optionally named providers per domain). Swapping providers is a wiring change, not a code change.
- Hooks – interceptors that run at
before,after,error, andfinallystages around every evaluation. This is where telemetry, audit, and tracing live, decoupled from business logic. - Evaluation context – the data targeting rules evaluate against (user id, plan, region, build). A
targetingKeyis the canonical identity used for consistent percentage bucketing.
The flow: your code calls the API with a flag key, a default value, and context. The hooks run, the provider resolves, and you get a value – never null. Defaults are mandatory and returned on any error, so a flag backend outage degrades to a known-safe value rather than an exception.
import { OpenFeature } from '@openfeature/server-sdk';
const client = OpenFeature.getClient();
// The default (false) is what you get if flagd is down, the flag is
// missing, or the type mismatches. Failure is always graceful.
const newCheckout = await client.getBooleanValue('new-checkout', false, {
targetingKey: user.id,
plan: user.plan,
region: user.region,
});
Note that the API never mentions flagd. That is the entire point.
2. Deploying flagd: sidecar vs. centralized, and the sync source
flagd is a daemon that reads flag definitions from one or more sync sources and serves evaluations over a gRPC (and HTTP) interface on port 8013. It holds flags in memory and re-evaluates on every call, so reads are sub-millisecond.
You have two topologies:
| Topology | Latency | Blast radius | Operational cost |
|---|---|---|---|
| Sidecar (one flagd per pod) | Lowest (loopback) | Per-pod | N containers to schedule |
| Centralized (a flagd Service) | Network hop | Shared service | One Deployment to run |
Start centralized for simplicity; move latency-critical services to sidecars later. The provider interface is identical, so it is a connection-string change.
The sync source is where flagd reads flags from. The common choices:
file– a mounted JSON/YAML file, ideal for GitOps where flags live in Git and a ConfigMap is reconciled by Argo CD or Flux.kubernetes– aFeatureFlagcustom resource (via the flagd Kubernetes operator), so flags are first-class cluster objects.grpc– flagd subscribes to a streaming source (e.g.flagd-proxy) for fan-out across many instances.
For a GitOps shop, file sync backed by a ConfigMap is the pragmatic default. Here is a centralized Deployment that reads a mounted file:
apiVersion: apps/v1
kind: Deployment
metadata:
name: flagd
namespace: platform-flags
spec:
replicas: 2
selector:
matchLabels: { app: flagd }
template:
metadata:
labels: { app: flagd }
spec:
containers:
- name: flagd
image: ghcr.io/open-feature/flagd:v0.12.8
args:
- start
- --uri
- file:/etc/flagd/flags.json
ports:
- { name: grpc, containerPort: 8013 }
- { name: ofrep, containerPort: 8016 } # OFREP HTTP
- { name: metrics, containerPort: 8014 }
volumeMounts:
- { name: flags, mountPath: /etc/flagd, readOnly: true }
volumes:
- name: flags
configMap: { name: flagd-flags }
---
apiVersion: v1
kind: Service
metadata:
name: flagd
namespace: platform-flags
spec:
selector: { app: flagd }
ports:
- { name: grpc, port: 8013, targetPort: 8013 }
- { name: ofrep, port: 8016, targetPort: 8016 }
flagd watches the mounted file and hot-reloads on change. With a ConfigMap, the kubelet propagates updates within its sync period (typically up to ~60s), and flagd picks them up automatically – no restart, no redeploy.
3. Authoring targeting rules: segments, rollouts, fractional bucketing
flagd flags are plain JSON validated against a published schema. Each flag has a state, a set of variants, a defaultVariant, and an optional targeting rule. Targeting uses JsonLogic plus flagd-specific operators.
Three building blocks cover most needs:
- Segments – ordered
ifbranches on context, returning a variant. - Percentage rollouts – the
fractionaloperator splits traffic by weight. - Stable bucketing –
fractionalhashes a key so the same user always lands in the same bucket.
{
"flags": {
"new-checkout": {
"state": "ENABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "off",
"targeting": {
"if": [
{ "in": ["beta", { "var": "groups" }] }, "on",
{
"fractional": [
{ "cat": [{ "var": "$flagd.flagKey" }, { "var": "targetingKey" }] },
["on", 20],
["off", 80]
]
}
]
}
}
}
}
Two things are deliberate here.
First, internal users (groups contains beta) always get on, short-circuiting the rollout. Segments evaluate top-down.
Second, the fractional operator’s first argument is the bucketing expression. The weights ["on", 20] and ["off", 80] are relative and need not sum to 100 – flagd normalizes them. flagd hashes the expression with MurmurHash3 into the [0, 100) space and assigns a bucket. By concatenating (cat) the flag key with the targetingKey, a given user gets an independent but stable assignment per flag: stable so they do not flicker between page loads, independent so they are not correlated across unrelated flags. If you omit the first argument, flagd defaults to bucketing on the targetingKey alone – correct for a single flag, but it correlates assignment across every flag, which you rarely want.
To advance a rollout, bump 20 to 50 to 100 in Git. Because bucketing is a stable hash of the same key, everyone already in the on 20% stays on as you widen – the rollout is monotonic, with no user flipped back off.
4. Wiring the SDK with context propagation
The default value is your circuit breaker; provide one on every call. The bigger architectural concern is context propagation – targeting context must follow the request through every layer without being threaded as an argument.
OpenFeature solves this with the transaction context propagator. You set the request’s identity once in middleware, and any evaluation deeper in the call stack inherits it.
import { OpenFeature, AsyncLocalStorageTransactionContextPropagator }
from '@openfeature/server-sdk';
import { FlagdProvider } from '@openfeature/flagd-provider';
OpenFeature.setTransactionContextPropagator(
new AsyncLocalStorageTransactionContextPropagator(),
);
await OpenFeature.setProviderAndWait(
new FlagdProvider({ host: 'flagd.platform-flags.svc', port: 8013 }),
);
// Express middleware: identity set once, inherited everywhere downstream.
app.use((req, _res, next) => {
OpenFeature.setTransactionContext(
{
targetingKey: req.user.id,
plan: req.user.plan,
region: req.headers['x-region'] as string,
groups: req.user.groups,
},
() => next(),
);
});
Now a deeply nested service evaluates flags without ever receiving a user object:
// Three layers down. No user plumbed in -- context is ambient.
async function priceCart(items: Item[]): Promise<number> {
const client = OpenFeature.getClient();
const dynamicPricing = await client.getBooleanValue('dynamic-pricing', false);
return dynamicPricing ? priceDynamically(items) : priceStatically(items);
}
setProviderAndWait blocks until flagd is connected, so you never serve traffic with an unready provider. Use the API surface symmetrically on the client tier – the @openfeature/web-sdk mirrors the same evaluation API in the browser, against the same flagd flags via OFREP.
5. Hooks for telemetry, audit, and tracing
Hooks are where a platform team adds cross-cutting behavior without touching a single feature call site. A hook implements any of before, after, error, finally. Register globally and it runs on every evaluation, everywhere.
This hook emits an OpenTelemetry span event on each resolution and logs an audit record – enough to answer “which variant did user X get for flag Y at time T,” which is exactly the question that surfaces in an incident review.
import { Hook, HookContext, EvaluationDetails, FlagValue }
from '@openfeature/server-sdk';
import { trace } from '@opentelemetry/api';
export const telemetryHook: Hook = {
after(ctx: HookContext, details: EvaluationDetails<FlagValue>) {
const span = trace.getActiveSpan();
span?.addEvent('feature_flag', {
// Semantic-convention attribute keys for feature flags.
'feature_flag.key': ctx.flagKey,
'feature_flag.provider_name': ctx.providerMetadata.name,
'feature_flag.result.variant': details.variant ?? 'unknown',
'feature_flag.result.reason': details.reason ?? 'unknown',
});
auditLog.write({
ts: Date.now(),
flag: ctx.flagKey,
targetingKey: ctx.context.targetingKey,
variant: details.variant,
reason: details.reason,
});
},
error(ctx: HookContext, err: Error) {
trace.getActiveSpan()?.recordException(err);
metrics.increment('flag_eval_error', { flag: ctx.flagKey });
},
};
// Registered once at boot; every getXxxValue in the codebase is now traced.
OpenFeature.addHooks(telemetryHook);
The attribute keys above follow the OpenTelemetry feature-flag semantic conventions, so any OTel-aware backend renders them as a first-class flag dimension. OpenFeature also ships a maintained @openfeature/open-telemetry-hooks package if you would rather not hand-roll it. Either way, telemetry is now a property of the platform, not a thing 40 teams each remember to add. (If tracing terms like span, attribute, and semantic convention are unfamiliar, see Observability fundamentals: logs, metrics, traces, SLOs.)
6. Swapping providers with zero code changes
The migration test: change the backend, recompile, ship – without editing a single evaluation call. Because the provider is the only vendor-aware seam, this holds.
// flagd today.
await OpenFeature.setProviderAndWait(
new FlagdProvider({ host: 'flagd.platform-flags.svc', port: 8013 }),
);
// LaunchDarkly tomorrow -- via the OpenFeature LD provider. Same client,
// same getBooleanValue calls, same hooks. Only this line changes.
import { LaunchDarklyProvider } from '@launchdarkly/openfeature-server-node';
await OpenFeature.setProviderAndWait(
new LaunchDarklyProvider(process.env.LD_SDK_KEY!),
);
// GO Feature Flag instead -- also a drop-in provider.
import { GoFeatureFlagProvider } from '@openfeature/go-feature-flag-provider';
await OpenFeature.setProviderAndWait(
new GoFeatureFlagProvider({ endpoint: 'https://gofeatureflag.internal' }),
);
OpenFeature also supports named providers bound to domains, so you can migrate incrementally – route the payments domain to LaunchDarkly while everything else stays on flagd:
OpenFeature.setProvider('payments', new LaunchDarklyProvider(key));
const paymentsClient = OpenFeature.getClient('payments'); // bound to LD
The one caveat worth stating plainly: the API contract is portable, but rule authoring is not. flagd’s JsonLogic targeting and LaunchDarkly’s rule builder are different surfaces. The SDK swap is free; you still re-create targeting rules in the new backend. That is a config migration, not a code migration – and it is the difference between a sprint and a quarter.
7. Governance: lifecycle, ownership, stale-flag cleanup
Flags are debt the moment they merge. Without governance you accrue hundreds of permanently-on flags whose removal nobody dares attempt. Encode ownership and intent in the flag definition itself.
flagd ignores unknown keys, so attach metadata at the flag level:
{
"flags": {
"new-checkout": {
"state": "ENABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "off",
"metadata": {
"owner": "team-checkout",
"jiraEpic": "CHK-1184",
"type": "release",
"createdAt": "2026-05-02",
"expiresAt": "2026-07-01"
}
}
}
}
Then enforce it in CI. A scheduled job opens a ticket (or fails the build) for any release flag past expiresAt:
#!/usr/bin/env bash
# stale-flag-check.sh -- fail if any release flag is past its expiry.
set -euo pipefail
today=$(date +%F)
jq -r '
.flags | to_entries[]
| select(.value.metadata.type == "release")
| select(.value.metadata.expiresAt < "'"$today"'")
| "STALE: \(.key) owner=\(.value.metadata.owner) expired=\(.value.metadata.expiresAt)"
' flags.json | tee stale.txt
[ -s stale.txt ] && { echo "Stale release flags found"; exit 1; } || echo "No stale flags"
Distinguish flag types: release flags are temporary and must expire; ops (kill switches) and experiment flags are longer-lived. Only release flags should trip the staleness gate – a permanent kill switch is a feature, not debt. Pair this with a linter that flags evaluation call sites whose flag key no longer exists in flags.json, catching the inverse rot: dead code branching on a deleted flag.
Verify
Confirm the stack end to end before trusting it in production.
# 1. flagd is healthy and serving.
kubectl -n platform-flags get pods -l app=flagd
kubectl -n platform-flags port-forward svc/flagd 8016:8016 &
# 2. Resolve a flag over the OFREP HTTP endpoint -- expect a variant + reason.
curl -s -X POST localhost:8016/ofrep/v1/evaluate/flags/new-checkout \
-H 'Content-Type: application/json' \
-d '{"context":{"targetingKey":"user-123","groups":["beta"]}}' | jq .
# 3. Prove stable bucketing: same key, repeated calls, identical variant.
for i in 1 2 3; do
curl -s -X POST localhost:8016/ofrep/v1/evaluate/flags/new-checkout \
-H 'Content-Type: application/json' \
-d '{"context":{"targetingKey":"user-987"}}' | jq -r .variant
done # -> three identical lines
# 4. Hot reload works: edit the ConfigMap, confirm flagd picks it up.
kubectl -n platform-flags logs -l app=flagd | grep -i "configuration updated"
Expected: step 2 returns {"value": true, "variant": "on", "reason": "TARGETING_MATCH"} for the beta user; step 3 prints the same variant three times (stable hash); step 4 shows a reload log line with no pod restart.
Implementing deterministic CI tests
Flag-driven branches must be testable without a live flagd. OpenFeature ships an in-memory provider for exactly this – you assert both branches deterministically, with zero network.
import { OpenFeature, InMemoryProvider } from '@openfeature/server-sdk';
describe('checkout flow', () => {
it('uses the new path when the flag is on', async () => {
await OpenFeature.setProviderAndWait(new InMemoryProvider({
'new-checkout': {
disabled: false,
variants: { on: true, off: false },
defaultVariant: 'on', // force ON for this test
},
}));
expect(await runCheckout(cart)).toEqual(expectedNewBehavior);
});
it('falls back to the old path when off', async () => {
await OpenFeature.setProviderAndWait(new InMemoryProvider({
'new-checkout': {
disabled: false,
variants: { on: true, off: false },
defaultVariant: 'off', // force OFF
},
}));
expect(await runCheckout(cart)).toEqual(expectedOldBehavior);
});
});
Because the same evaluation API resolves against the in-memory provider, the code under test is byte-identical to production – only the provider differs. Run both branches in CI on every PR and a half-rolled-out flag can never hide a broken code path.
Enterprise scenario
A payments platform team at a mid-size fintech ran LaunchDarkly across ~60 services. After an acquisition, a data-residency mandate landed: EU customer evaluations could not transit a US-hosted SaaS, and the audit trail had to live in their own SIEM. Ripping out LaunchDarkly meant touching every call site – months of regression risk on a payments path – so the proposal kept stalling.
The constraint that broke the deadlock: they did not need to leave LaunchDarkly everywhere, only for EU traffic, and only without a code rewrite.
They adopted OpenFeature as a refactor – mechanically replacing ldClient.variation(...) with client.getBooleanValue(...), no behavior change – then used named providers to route by region. EU services bound to a self-hosted flagd; everything else stayed on the LaunchDarkly provider during a phased cutover. The audit requirement was satisfied by a single global hook streaming every resolution to the SIEM, identical across both providers.
// Region decides the backend; the 60 services' evaluation code is untouched.
const region = process.env.DEPLOY_REGION;
const provider = region === 'eu'
? new FlagdProvider({ host: 'flagd.eu.internal', port: 8013 })
: new LaunchDarklyProvider(process.env.LD_SDK_KEY!);
await OpenFeature.setProviderAndWait(provider);
OpenFeature.addHooks(siemAuditHook); // same audit trail, both backends
The migration shipped in six weeks instead of two quarters. The decisive insight: once the vendor lives behind the OpenFeature seam, “which backend” becomes a deployment variable, and residency, audit, and cost become operational knobs rather than rewrites.
Going deeper
The sections above get a platform running. What follows is the material that separates “we use flags” from “we operate a flag platform”: provider lifecycle, the two ways flagd can resolve, the protocol that makes even the provider swappable, experimentation, backend-migration safety nets, and the security and scale edges.
Provider status and events — don’t serve traffic on a cold provider
A provider is not a boolean “connected or not.” OpenFeature models a lifecycle: NOT_READY → READY, and from there STALE, ERROR, or FATAL. setProviderAndWait blocks until READY, which is why it belongs at boot before you accept traffic. But flagd can also change its mind at runtime — a config push, a dropped stream — and the SDK emits events you should handle rather than ignore.
import { OpenFeature, ProviderEvents } from '@openfeature/server-sdk';
// Bust any per-request memoization when the ruleset changes underfoot.
OpenFeature.addHandler(ProviderEvents.ConfigurationChanged, (details) => {
cache.invalidate(); // your caches, not the SDK's
log.info('flag config changed', { flagsChanged: details?.flagsChanged });
});
// STALE means "serving last-known-good"; ERROR means defaults are flowing.
OpenFeature.addHandler(ProviderEvents.Stale, () => metrics.gauge('flags.stale', 1));
OpenFeature.addHandler(ProviderEvents.Error, () => alertOnCall('flag provider ERROR'));
The ConfigurationChanged event carries the set of changed flag keys, which is what the browser @openfeature/web-sdk uses to re-evaluate and re-render only the affected UI. On the server this is your signal to invalidate any cache you layered on top of evaluations. Treat STALE as a warning (still safe, using last-known-good) and ERROR/FATAL as “defaults are flowing” — the exact moment your mandatory default earns its place in every call.
In-process vs RPC: two resolvers, very different failure modes
The flagd provider can resolve flags two ways, and the choice changes both latency and blast radius.
| Resolver | How it evaluates | Latency | If flagd blips |
|---|---|---|---|
| RPC (default) | Each eval is a gRPC/OFREP call to flagd; flagd is the single evaluator | Sub-millisecond (network hop) | Provider goes STALE/ERROR, defaults flow |
| In-process | Provider streams the whole ruleset via flagd’s flag-sync gRPC (port 8015) and evaluates locally in your process |
Sub-microsecond (no hop) | Keeps evaluating on last-synced rules; survives the blip |
import { FlagdProvider } from '@openfeature/flagd-provider';
// In-process: pull the ruleset over the sync service, evaluate in-app.
await OpenFeature.setProviderAndWait(
new FlagdProvider({ resolverType: 'in-process', host: 'flagd', port: 8015 }),
);
In-process trades memory (every pod holds the full ruleset) for latency and resilience — evaluation cannot fail on a network partition because there is no network in the hot path. When one flagd would become a fan-out bottleneck for hundreds of in-process pods, put flagd-proxy in front: it multiplexes a single upstream sync into many downstream subscribers. Rule of thumb: RPC for a handful of services and centralized simplicity; in-process (with flagd-proxy) when you need microsecond evaluation at high pod counts.
OFREP — the escape hatch that makes even the provider generic
Section 4 mentioned OFREP in passing; it deserves its own beat. OFREP (the OpenFeature Remote Evaluation Protocol) is a standardized REST contract for remote flag evaluation. Because it is a spec, any OFREP-compliant server can be hit by the generic @openfeature/ofrep-provider (or @openfeature/ofrep-web-provider in the browser) — no vendor-specific provider package at all. flagd serves OFREP on 8016; a growing list of flag vendors do too.
That is portability one level deeper than the provider seam: with the flagd provider you can swap flagd for another provider; with OFREP you can swap the flagd server for any OFREP server while keeping the same generic provider. It is also how the curl calls in the Verify section resolve a flag with no SDK on the wire at all.
Experimentation: pairing exposure with outcome via track()
A percentage rollout tells you who saw a variant; an experiment needs to correlate that exposure with a business outcome (conversion, revenue, latency). OpenFeature’s tracking API closes that loop. Your after hook already logs the exposure (which targetingKey got which variant); client.track(...) records the downstream event against the same identity.
const client = OpenFeature.getClient();
// Exposure is logged by the global after-hook when this resolves.
const newCheckout = await client.getBooleanValue('new-checkout', false);
// ... user completes the purchase ...
client.track('checkout-completed', undefined, { value: 79.9, currency: 'USD' });
An experimentation backend (warehouse-native, GrowthBook, Statsig, or your own) joins the two streams on targetingKey and computes per-variant conversion with statistical significance. The platform contribution is that both signals — exposure and outcome — flow through the same vendor-neutral SDK, so switching experimentation tools is a backend change, not a re-instrumentation.
Multi-provider: dark-launching the flag backend itself
Migrating backends (Section 6) has a hidden risk: does the new backend return the same variant the old one did? @openfeature/multi-provider evaluates across several providers under a strategy and lets you verify that before you cut over.
import { MultiProvider } from '@openfeature/multi-provider';
import { ComparisonStrategy } from '@openfeature/multi-provider';
// Serve flagd's answer, but evaluate LaunchDarkly in parallel and log any
// disagreement — a shadow launch of the migration, with no user impact.
await OpenFeature.setProviderAndWait(
new MultiProvider(
[
{ provider: new FlagdProvider({ host: 'flagd', port: 8013 }) },
{ provider: new LaunchDarklyProvider(process.env.LD_SDK_KEY!) },
],
new ComparisonStrategy(/* fallback = first provider */),
),
);
Strategies include FirstMatchStrategy (first non-error wins — a resilience pattern), FirstSuccessfulStrategy, and ComparisonStrategy (evaluate all, serve one, flag mismatches). The comparison mode is the safest possible migration: you get a mismatch report before trusting the new rules, which is exactly the “re-author the rules” risk called out in Section 6, made observable.
Security and scale edges
- flagd is unauthenticated by default. Anyone who can reach
8013/8016can read every flag and evaluate with arbitrary context — including impersonating your targeting (groups: ["beta"]). Keep flagd in-cluster only:ClusterIP, aNetworkPolicythat admits only your workloads, never an Ingress. Do not put secrets in variant values — a flag decides behavior, not credentials. - Context is potentially PII.
targetingKey,plan, andregionmay be personal data; the audit hook that streams context to a SIEM is precisely where the enterprise scenario’s data-residency rule bites. Redact or hash what the audit trail does not need. - Cost is config fan-out, not evaluation. Evaluation is effectively free (in-memory, per call). What scales is distributing the ruleset: N sidecars each watching a source, or a central flagd as a shared dependency (a single blast radius). Bound targeting-rule complexity — deeply nested JsonLogic runs on every evaluation.
- Targeting has more operators than
fractional.sem_vercompares semantic versions (gate a feature to app builds>=2.4.0),starts_with/ends_withmatch string prefixes/suffixes (region codes, emails), and the injected$flagd.timestampenables time-boxed rules. Order still matters — allow-lists above the percentage roll, because segments short-circuit top-down.
{
"flags": {
"hardware-accel": {
"state": "ENABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "off",
"targeting": {
"if": [
{
"and": [
{ "sem_ver": [{ "var": "appVersion" }, ">=", "2.4.0"] },
{ "starts_with": [{ "var": "region" }, "eu-"] }
]
},
"on",
"off"
]
}
}
}
}
Version and API caveats
- Server SDK ≠ web SDK. The server SDK evaluates per request with dynamic context passed on each call; the web SDK holds a single static evaluation context set once per user session and re-evaluates on context change. Do not port the per-call-context pattern to the browser — set context once, then read.
- Pin the flagd image and schema-validate flags. The Deployment pins
flagd:v0.12.8deliberately; the flag JSON schema is versioned. Validateflags.jsonagainst the published schema in CI so a malformed rule fails the PR, not production. - Domains, not “client names.” Current OpenFeature terminology is domains for the logical grouping bound to named providers (older docs say “client name”). Same mechanism, current name.
If you are wiring flags into an automated release pipeline (canary analysis, Argo Rollouts, progressive delivery gates), the companion lesson GitHub Actions + Argo CD progressive delivery with policy gates shows the deployment side of the same story.
Practice challenges
Work these in order — each builds on the last, beginner to advanced. Try before opening the solution.
1. (Beginner) Make a call fail safe. A teammate wrote await client.getBooleanValue('new-nav') and it will not compile. Fix it so a flagd outage cannot enable the new navigation.
<details><summary>Solution</summary>
const showNewNav = await client.getBooleanValue('new-nav', false);
The signature requires a default: getBooleanValue(key, defaultValue, context?). Choosing false (the old, safe behavior) means any error — outage, missing flag, type mismatch — degrades to the known-good path. Why: the default is the circuit breaker; the compiler forces you to declare your safe fallback.
</details>
2. (Beginner) Resolve a flag with no SDK. Prove flagd works using only curl — no application code — for new-checkout, as a beta user.
<details><summary>Solution</summary>
curl -s -X POST localhost:8016/ofrep/v1/evaluate/flags/new-checkout \
-H 'Content-Type: application/json' \
-d '{"context":{"targetingKey":"user-123","groups":["beta"]}}' | jq .
# -> {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}
Why: OFREP is a plain REST contract on port 8016, so any HTTP client can evaluate a flag. This is your fastest smoke test that flagd is serving and your targeting rule matches.
</details>
3. (Intermediate) Author a sticky 10% rollout with a beta bypass. Write the targeting for new-checkout: users whose groups include beta always get on; everyone else gets a stable 10% rollout that will not flicker between page loads.
<details><summary>Solution</summary>
{
"if": [
{ "in": ["beta", { "var": "groups" }] }, "on",
{
"fractional": [
{ "cat": [{ "var": "$flagd.flagKey" }, { "var": "targetingKey" }] },
["on", 10],
["off", 90]
]
}
]
}
Why: the segment (in beta) short-circuits top-down; fractional over cat(flagKey, targetingKey) gives a MurmurHash3 bucket that is stable per user and independent per flag.
</details>
4. (Intermediate) Widen the rollout without flipping anyone back. The 10% above looks healthy. Take it to 50% such that every user already seeing on keeps seeing it. What changes, and why is it safe?
<details><summary>Solution</summary>
Change only the weights: ["on", 10], ["off", 90] → ["on", 50], ["off", 50]. The bucketing key (cat(flagKey, targetingKey)) is unchanged, so each user’s hash lands in the same spot on [0,100); widening the on band can only add users, never remove them. Why: stable-hash bucketing makes the rollout monotonic — no user is flipped back off as you widen.
</details>
5. (Advanced) Test both branches deterministically. Write a CI test that exercises the on and off paths of runCheckout with no live flagd and no network.
<details><summary>Solution</summary>
import { OpenFeature, InMemoryProvider } from '@openfeature/server-sdk';
for (const [variant, expected] of [['on', expectedNew], ['off', expectedOld]] as const) {
it(`checkout: ${variant} path`, async () => {
await OpenFeature.setProviderAndWait(new InMemoryProvider({
'new-checkout': { disabled: false, variants: { on: true, off: false }, defaultVariant: variant },
}));
expect(await runCheckout(cart)).toEqual(expected);
});
}
Why: the InMemoryProvider resolves through the same evaluation API, so the code under test is byte-identical to production — only the provider differs. A half-rolled-out flag can never hide a broken branch.
</details>
6. (Advanced) Migrate one domain without touching the rest. 60 services run on flagd. Move only the payments domain onto LaunchDarkly, leaving the other 59 services’ code and backend untouched. Show the wiring.
<details><summary>Solution</summary>
// Default provider stays flagd for the 59 services — no change there.
await OpenFeature.setProviderAndWait(new FlagdProvider({ host: 'flagd', port: 8013 }));
// Bind ONLY the payments domain to LaunchDarkly.
OpenFeature.setProvider('payments', new LaunchDarklyProvider(process.env.LD_SDK_KEY!));
// Payments code fetches its domain-bound client; everything else is unchanged.
const paymentsClient = OpenFeature.getClient('payments');
const instantPayout = await paymentsClient.getBooleanValue('instant-payout', false);
Why: named providers bound to domains make “which backend” a per-domain wiring decision, so a migration is incremental and reversible rather than a big-bang cutover. Remember: you still re-author the payments targeting rules in LaunchDarkly — the API is portable, the rule surface is not.
</details>
Common beginner mistakes
- “OpenFeature is a flag vendor that competes with LaunchDarkly.” It is not a flag service at all — it is a specification plus SDK that needs a provider underneath to actually resolve anything. Right model: OpenFeature is the USB standard; flagd, LaunchDarkly, and GO Feature Flag are the devices you plug in.
- “I can skip the default, or default to the new behavior.” The default is mandatory and it is your outage fallback. Defaulting to the new path means a flagd blip silently enables untested code for everyone. Right model: the default is the known-safe old behavior; the flag only ever upgrades you away from it.
- “A percentage rollout is random per request.” Random means a user flips between old and new on every page load — a broken, flickering experience. Right model: bucket on a stable hash of the
targetingKeyso a user’s assignment is sticky across requests. - “Bucketing on the targetingKey alone is fine.” It correlates every flag: a user in the unlucky tail lands in the
offtail of every flag at once. Right model: hashcat(flagKey, targetingKey)so each flag’s assignment is independent. - “Deploying the code releases the feature.” That is the exact coupling flags exist to break. Right model: ship dark (off by default) so deploy is a non-event, then release by flipping a flag — two separate, differently-risky moments.
- “Flags are cheap; leave them in.” Every live flag is a branch in the code and a row in your test matrix; hundreds of stale ones become dead code nobody dares delete. Right model: a
releaseflag is debt with anexpiresAt; the CI gate is what forces cleanup. - “flagd is a database I write to at runtime.” flagd is read-only in the hot path; the source of truth is
flags.jsonin Git (GitOps). You change a flag by changing Git and letting flagd hot-reload, not by POSTing to flagd. Right model: Git is the control plane, flagd is the read replica. - “A vendor swap re-creates my targeting rules for me.” The evaluation API is portable; the rule authoring surface is not. Right model: the SDK swap is free, but you re-author (or
MultiProvider-verify) the rules in the new backend — that is a config migration, not a code one.
Glossary
- Feature flag (toggle): a runtime switch that decides whether a piece of behavior is active for a given request or user, changeable without a code deploy.
- OpenFeature: a CNCF Incubating specification plus SDKs that standardize the flag evaluation API so application code never names a vendor.
- flagd: OpenFeature’s open-source reference flag daemon — reads flags from a sync source, holds them in memory, serves evaluations over gRPC and OFREP.
- Provider: the adapter that actually resolves a flag (flagd, LaunchDarkly, GO Feature Flag, in-memory). The only vendor-aware seam; swapping it is a wiring change.
- Evaluation API: the typed, vendor-free surface your code calls —
getBooleanValue,getStringValue,getNumberValue,getObjectValue, and their*Detailsvariants. - Evaluation context: the data targeting rules run against — user id, plan, region, build, groups.
targetingKey: the canonical identity in the context; the stable key used for consistent percentage bucketing.- Hook: an interceptor (
before/after/error/finally) that runs around every evaluation; where telemetry, audit, and tracing live, decoupled from feature code. - Variant: a named possible value of a flag (e.g.
on→true,off→false); flags can have more than two. defaultVariant: the variant flagd returns when no targeting rule matches. Distinct from the SDK-side default value returned on error.- Default value: the mandatory fallback passed on every SDK call; returned on any error (outage, missing flag, type mismatch). Your circuit breaker.
- Targeting rule: the logic (JsonLogic + flagd operators) that maps a context to a variant.
- Segment: an ordered
ifbranch on context that returns a variant (e.g. “internal users geton”). fractionaloperator: flagd’s percentage-split operator; hashes a bucketing expression with MurmurHash3 into[0,100)and assigns a weighted variant.- Sticky / stable bucketing: hashing a fixed key so the same user always lands in the same bucket — no flicker between requests, and a monotonic rollout as you widen.
- Progressive rollout / progressive delivery: releasing a change to a growing slice of traffic (1% → 10% → 100%) while watching signals, ready to halt or reverse.
- Kill switch: an
ops-type flag flipped to off to instantly disable a misbehaving path — seconds, no rollback deploy. - Decoupling deploy from release: shipping code dark so deployment is a non-event, then releasing separately by flipping a flag.
- Sync source: where flagd reads flags — a mounted
file/ConfigMap, akubernetesFeatureFlagcustom resource, or agrpcstream. - OFREP (OpenFeature Remote Evaluation Protocol): a standardized REST contract for remote flag evaluation, so a generic provider (or plain
curl) can hit any OFREP-compliant server; flagd serves it on8016. - gRPC: the high-performance RPC protocol flagd uses for evaluation (
8013) and for the flag-sync stream (8015). - Named provider / domain: binding a specific provider to a logical group of clients (a domain) so different parts of the app use different backends — the key to incremental migration.
- Transaction context propagator: the mechanism (e.g.
AsyncLocalStorage) that carries targeting context through a request so nested code evaluates flags without receiving a user object. - In-memory provider: a provider that resolves from an in-code map, used to test both flag branches deterministically in CI with no network.
- In-process vs RPC resolver: in-process evaluates the streamed ruleset locally (sub-microsecond, survives flagd blips); RPC calls flagd per evaluation (sub-millisecond, flagd is the single evaluator).
- Provider status / events: the lifecycle (
NOT_READY→READY→STALE/ERROR/FATAL) and emitted events (ConfigurationChanged,Stale,Error) you handle to keep caches and alerts honest. - Tracking API (
track()): the OpenFeature method that records a business outcome against atargetingKey, pairing flag exposure with result for experimentation. - Flag debt / stale flag: a temporary
releaseflag left in the code past its purpose; live branch, test-matrix bloat, and dead code if never cleaned up. - Flag type (release / ops / experiment): metadata distinguishing temporary release flags (must expire), permanent kill switches, and longer-lived experiment flags — only
releaseflags trip the staleness gate. - Hot reload: flagd re-reading its sync source on change (within the ConfigMap sync period) and serving new rules with no pod restart or redeploy.
- JsonLogic: the JSON-encoded boolean/expression language flagd’s targeting is built on, extended with flagd operators like
fractional,sem_ver, andstarts_with.