Go SDK
Evaluate flags inside your Go services, with the standard library only and an OpenFeature provider and OpenTelemetry hook in their own modules.
The Go SDK keeps a copy of your environment’s flags in memory and evaluates them in your process, so a flag check never waits for the network and your users’ attributes never leave your service. It needs Go 1.25 or newer and nothing outside the standard library.
Install
go get github.com/featflags/featflags-go
The OpenFeature provider and the OpenTelemetry hook are separate modules, so the SDK itself stays dependency-free.
Create a client
Create one client per environment key when your service starts and share it: it’s safe for concurrent use. On a server, use the environment’s server key (sk_…), which you keep secret.
import featflags "github.com/featflags/featflags-go"
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) // bounds the first fetch
defer cancel()
flags, err := featflags.New(ctx, featflags.Config{
Key: "sk_…",
CDNURL: "https://flags.featflags.io",
EventsURL: "https://events.featflags.io",
})
if err != nil {
return err // only for an invalid Config
}
defer flags.Close(context.Background()) // stops polling and sends what's left
New waits for the first copy of your flags within its ctx. If that fetch fails, the client starts anyway: it serves the Bootstrap document, or your fallbacks with reason not_found, until a poll succeeds. Version tells you which version of your flags is in use.
Config field |
Default | What |
|---|---|---|
Key |
required | The environment’s key. |
CDNURL |
required | https://flags.featflags.io. |
EventsURL |
off | https://events.featflags.io. Without it the SDK sends nothing: no evaluation counts, no experiment data. |
ExperimentKey |
none | The environment’s experiment key, xk_…: see Experiments. |
PollInterval |
5 s for sk_… keys, 60 s otherwise |
How often it checks for changes, ±10% jitter. Negative turns polling off. |
FlushInterval |
60 s | How often it sends evaluation counts. The first send waits up to one extra interval at random. |
OnExposure |
none | Called the first time each (context key, flag, variant) is evaluated, to forward to your own analytics. |
Hooks |
none | Run around every evaluation: see Hooks. |
Bootstrap |
none | A *Document (featflags.ParseDocument) to evaluate against until the first fetch answers. |
HTTPClient |
10 s timeout | Used for every request the SDK makes. |
Now |
time.Now |
The clock for scheduled rollout steps; tests pin it. |
MembershipCache |
1024 | List shards kept in memory (about 2 MB) when lists are too big to download whole. |
MembershipTimeout |
500 ms | How long one evaluation waits for list shards it doesn’t have yet. |
Warnings go to log/slog’s default logger.
Evaluate flags
user := featflags.NewContext(map[string]any{"key": "u-alice", "plan": "pro"})
if flags.Bool(r.Context(), "new-checkout", user, false) {
// …
}
d := flags.StringDetail(r.Context(), "pricing-page", user, "v1")
log.Printf("pricing-page: %s (%s, rule %q)", d.Value, d.Reason, d.RuleID)
Bool, String, Float and JSON return the flag’s value, or your fallback when the flag doesn’t exist, the context is invalid, or the value has another type. The …Detail variants (BoolDetail, StringDetail, FloatDetail, JSONDetail) also return the variant key and the reason: RuleID with ReasonRule, Prerequisite with ReasonPrerequisiteFailed. JSON returns values as encoding/json decodes into an any (numbers are float64), and a fresh copy of arrays and objects every call.
Every getter takes a context.Context first. Evaluation doesn’t use it; it’s passed to your hooks, so a tracing hook finds the request’s span.
Contexts
A context is who or what you’re evaluating for, in LaunchDarkly’s shape. NewContext takes a map[string]any, or anything encoding/json decoded:
featflags.NewContext(map[string]any{"key": "u-alice", "email": "alice@acme.com"}) // a user, the default kind
featflags.NewContext(map[string]any{"kind": "org", "key": "acme", "plan": "team"}) // another kind
featflags.NewContext(map[string]any{ // several at once
"kind": "multi",
"user": map[string]any{"key": "u-alice"},
"org": map[string]any{"key": "acme", "plan": "team"},
})
Attribute values are strings, numbers (any Go numeric type or json.Number), booleans, or slices of those; nil, maps and structs count as missing. Decode JSON with json.Decoder.UseNumber to keep integer keys above 2⁵³ exact for percentage rollouts. An invalid context (no key, a bad kind) gets your fallback with reason error; Context.Valid checks first. Concepts has more on kinds.
Lists and private segments
Targets and segment lists of 64 keys or fewer travel inside your flags. Longer ones, up to millions of keys, are split into small shards the SDK fetches separately:
- When all of an environment’s lists total under 50 MB, the client downloads every shard in the background as soon as your flags arrive.
- Otherwise it fetches a shard the first time an evaluation needs it and keeps the most recently used ones (
MembershipCache).
Evaluation is synchronous, so an evaluation that needs a shard the client doesn’t have yet blocks its goroutine until the shard arrives or MembershipTimeout passes (500 ms, shared by the whole evaluation). Past that, the list doesn’t match, the shard keeps loading for later evaluations, and the reason is ReasonMembershipPending: the value is what the flag serves without that list. A failed shard fetch reports the same and is retried by the next evaluation that needs it.
Private segments can’t be read in a browser without a token from your backend, so nobody can list who’s on them. Compute the tokens at sign-in and hand them to the browser:
tokens := flags.MembershipToken(user) // map[string]string{"user": "8ecc…"}
The TypeScript SDK takes them as identify(ctx, { membershipTokens: tokens }). MembershipToken is empty when the environment has no private lists, or for an invalid context.
Staying up to date
The client checks for changes every 5 seconds with a server key, so your servers have a change within about 5 seconds. Checks are cheap: nothing is downloaded unless something changed. It never goes back to an older version of your flags, and when a check fails it keeps serving the last good copy, so a featflags outage leaves your flags as they were. Refresh checks now.
Experiments
With EventsURL set and the environment’s experiment key, the client reports what experiments need: which variant each unit saw, and the events you track. Keep the key with your other secrets; featflags never holds it.
flags, err := featflags.New(ctx, featflags.Config{
Key: "sk_…", CDNURL: "https://flags.featflags.io", EventsURL: "https://events.featflags.io",
ExperimentKey: os.Getenv("FEATFLAGS_EXPERIMENT_KEY"),
})
on := flags.Bool(r.Context(), "new-checkout", user, false) // counts as an exposure if an experiment's audience served it
flags.Track(user, "checkout.completed", order.Total) // an event key and at most one number; nothing else
flags.Track(user, "page.error")
Track sends an event to every running experiment whose metrics count that event key, and nothing when none does, so tracking freely costs nothing. It doesn’t check that the user was exposed here: a conversion often happens on your server while the exposure happened in the browser. Event keys match [a-z0-9_.:-]{1,64}; a bad key or value is logged once and dropped. Experiment data goes out with the evaluation counts, and only while the key matches the environment’s: after you rotate it, a deploy with the old key sends nothing and logs why.
Browsers and mobile apps get seeds instead of the key, which would be public in an app. Hand them over at sign-in, with the membership tokens:
seeds, ok := flags.ExperimentSeeds(user) // ok is false without an experiment key
json.NewEncoder(w).Encode(map[string]any{"membershipTokens": flags.MembershipToken(user), "experimentSeeds": seeds})
The client SDKs take them as experimentSeeds when they identify the user. Seeds carry the key’s fingerprint, so after a rotation an app sends nothing until it gets new ones. How units are computed.
Hooks
A hook runs around every evaluation, for logging, metrics or tracing: BeforeEvaluation in the order you registered hooks, AfterEvaluation in reverse, with the detail the caller gets. Each stage gets the context.Context passed to the getter. Embed BaseHook to skip the stages you don’t need. A stage that panics is logged and skipped.
type timer struct{ featflags.BaseHook }
func (timer) Name() string { return "timer" }
func (timer) BeforeEvaluation(_ context.Context, _ featflags.EvaluationSeries, data featflags.SeriesData) featflags.SeriesData {
data["start"] = time.Now()
return data
}
func (timer) AfterEvaluation(_ context.Context, s featflags.EvaluationSeries, data featflags.SeriesData, d featflags.Detail[any]) featflags.SeriesData {
log.Printf("%s %s: %s in %s", s.Method, s.FlagKey, d.Reason, time.Since(data["start"].(time.Time)))
return data
}
Register them in Config.Hooks. The hooks contract has the details every SDK shares.
OpenTelemetry
The hook in github.com/featflags/featflags-go/otel adds a feature_flag.evaluation event to the span in the evaluation’s context.Context, with OpenTelemetry’s semantic convention attributes:
import ffotel "github.com/featflags/featflags-go/otel"
flags, _ := featflags.New(ctx, featflags.Config{Key: "sk_…", CDNURL: "https://flags.featflags.io", Hooks: []featflags.Hook{ffotel.Hook{}}})
on := flags.Bool(r.Context(), "new-checkout", user, false) // recorded on the request's span
OpenFeature
github.com/featflags/featflags-go/openfeature is an OpenFeature provider. The provider doesn’t own the client, so close it yourself.
import (
"github.com/open-feature/go-sdk/openfeature"
ffopenfeature "github.com/featflags/featflags-go/openfeature"
)
openfeature.SetProviderAndWait(ffopenfeature.NewProvider(flags))
client := openfeature.NewClient("checkout")
on, _ := client.BooleanValue(ctx, "new-checkout", false, openfeature.NewEvaluationContext("u-alice", map[string]any{"plan": "pro"}))
The targeting key is the context key. Add a kind attribute for another kind, or kind: "multi" with each member as a map with its own targetingKey. OpenFeature and OpenTelemetry has how reasons map.
Shutting down
The client sends evaluation counts and experiment data once a minute. Call Close before a short-lived job or a server exits, so the last minute isn’t lost; it stops polling and sends what’s waiting. Flush sends now without closing.
Evaluation counts carry the flag, variant, reason, rule and version, and nothing about the context.