Java SDK

Evaluate flags inside your Java and Kotlin services on the JVM, with Jackson as the only dependency.

The Java 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 Java 17 or newer, works from Kotlin as is, and depends only on Jackson databind; HTTP goes through java.net.http. For Android apps, use the Android SDK.

Install

With Gradle:

implementation("io.featflags:featflags-java:0.1.0")
implementation("io.featflags:featflags-openfeature:0.1.0") // the OpenFeature provider
implementation("io.featflags:featflags-otel:0.1.0")        // the OpenTelemetry hook

With Maven:

<dependency>
  <groupId>io.featflags</groupId>
  <artifactId>featflags-java</artifactId>
  <version>0.1.0</version>
</dependency>

The artifacts are on Maven Central under io.featflags.

Create a client

Create one client per environment key and share it: it’s safe to use from many threads. On a server, use the environment’s server key (sk_…), which you keep secret.

import io.featflags.Context;
import io.featflags.Detail;
import io.featflags.FeatflagsClient;

FeatflagsClient flags = FeatflagsClient.builder("sk_…", "https://flags.featflags.io")
        .eventsUrl("https://events.featflags.io")
        .build();

build() fetches the first copy of your flags, bounded by timeout. 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. Polling and sending run on two daemon threads. Warnings go to the io.featflags System.Logger: java.util.logging, unless a System.LoggerFinder such as SLF4J’s slf4j-jdk-platform-logging routes them elsewhere.

Builder method Default What
builder(key, cdnUrl) required The environment’s key and 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. Duration.ZERO 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 with an Exposure the first time each (context key, flag, variant) is evaluated, to forward to your own analytics.
bootstrap none A Document (Document.parse(json)) to evaluate against until the first fetch answers.
timeout 10 s HTTP timeout.
clock system clock Epoch milliseconds, 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.
hook none Adds a hook to run around every evaluation: see Hooks.

Evaluate flags

Context user = Context.of(Map.of("key", "u-alice", "plan", "pro", "groups", List.of("qa")));

if (flags.boolVariation("new-checkout", user, false)) {
    // …
}

Detail<String> d = flags.stringVariationDetail("pricing-page", user, "v1");
log.info("pricing-page: {} ({}, rule {})", d.value(), d.reason(), d.ruleId());

boolVariation, stringVariation, doubleVariation and jsonVariation 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 return a Detail record with value, variant and the reason: ruleId with Reason.RULE, prerequisite with Reason.PREREQUISITE_FAILED. Reason.toString() gives the reason’s name as other SDKs spell it (prerequisite_failed). doubleVariation serves any number; jsonVariation takes and returns a Jackson JsonNode, a fresh copy every call.

Contexts

A context is who or what you’re evaluating for, in LaunchDarkly’s shape. Context.of(Map) takes it as a map, Context.fromJson(JsonNode) as JSON:

Context.of(Map.of("key", "u-alice", "email", "alice@acme.com"));     // a user, the default kind
Context.of(Map.of("kind", "org", "key", "acme", "plan", "team"));    // another kind
Context.of(Map.of("kind", "multi",                                   // several at once
        "user", Map.of("key", "u-alice"),
        "org", Map.of("key", "acme", "plan", "team")));

Attribute values are strings, numbers (any Number; BigInteger keeps integer keys of any size exact for percentage rollouts), booleans, or collections and arrays of those; null, maps, nested collections and other objects count as missing. Pass instants as RFC 3339 strings or epoch milliseconds. A Context is immutable, so build it once to evaluate many flags; isValid() and key(kind) inspect it. An invalid context (no key, a bad kind) gets your fallback with Reason.ERROR. 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 thread 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 Reason.MEMBERSHIP_PENDING: 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:

Map<String, String> tokens = flags.membershipToken(user); // {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. 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 and throws IOException on failure.

Regex conditions run on the SDK’s own matcher, never on java.util.regex, so a pattern can’t make an evaluation slow: time grows linearly with the attribute’s length.

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.

FeatflagsClient flags = FeatflagsClient.builder("sk_…", "https://flags.featflags.io")
        .eventsUrl("https://events.featflags.io")
        .experimentKey(System.getenv("FEATFLAGS_EXPERIMENT_KEY"))
        .build();

boolean on = flags.boolVariation("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 an optional 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, never thrown. 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:

Optional<ExperimentSeeds> seeds = flags.experimentSeeds(user); // empty without an experiment key; serializes with Jackson
response.json(Map.of("membershipTokens", flags.membershipToken(user), "experimentSeeds", seeds.orElse(null)));

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. Implement io.featflags.Hook; both stages pass data through unless you override them. A stage that throws is logged and skipped.

Hook timer = new Hook() {
    public String name() { return "timer"; }

    public Map<String, Object> beforeEvaluation(EvaluationSeries series, Map<String, Object> data) {
        data.put("start", System.nanoTime());
        return data;
    }

    public Map<String, Object> afterEvaluation(EvaluationSeries series, Map<String, Object> data, Detail<?> detail) {
        log.info("{} {}: {} in {} ns", series.method(), series.flagKey(), detail.reason(), System.nanoTime() - (long) data.get("start"));
        return data;
    }
};
FeatflagsClient flags = FeatflagsClient.builder("sk_…", "https://flags.featflags.io").hook(timer).build();

The hooks contract has the details every SDK shares.

OpenTelemetry

io.featflags.otel.OtelHook, in the featflags-otel artifact (which depends only on io.opentelemetry:opentelemetry-api), adds a feature_flag.evaluation event to the current span, with OpenTelemetry’s semantic convention attributes:

FeatflagsClient flags = FeatflagsClient.builder("sk_…", "https://flags.featflags.io").hook(new OtelHook()).build();

OpenFeature

io.featflags.openfeature.FeatflagsProvider, in the featflags-openfeature artifact, is an OpenFeature provider for dev.openfeature:sdk. The provider doesn’t own the client, so close it yourself.

OpenFeatureAPI.getInstance().setProviderAndWait(new FeatflagsProvider(flags));
Client client = OpenFeatureAPI.getInstance().getClient("checkout");
boolean on = client.getBooleanValue("new-checkout", false, new ImmutableContext("u-alice", Map.of("plan", new Value("pro"))));

The targeting key is the context key. Add a kind attribute for another kind, or kind: "multi" with each member as a structure with its own targetingKey. Instant attributes count as missing: pass instants as RFC 3339 strings or epoch milliseconds. 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 waits for a check or send in flight, then sends what’s waiting. The client is AutoCloseable, so try-with-resources works. flush() sends now without closing, and throws on failure.

Evaluation counts carry the flag, variant, reason, rule and version, and nothing about the context.