Skip to content

Optimizely

zio-openfeature-optimizely serves flags from Optimizely Feature Experimentation. It is built directly on the official Optimizely Java SDK, which downloads your project's datafile and makes every decision locally, in your process.

libraryDependencies += "io.github.etacassiopeia" %% "zio-openfeature-optimizely" % "<version>"

The Optimizely SDK needs a JSON parser but does not declare one, so this module brings jackson-databind. If gson is also on your classpath, the SDK prefers it; -Doptimizely.default_parser= picks one explicitly. If no parser can be found, OptimizelyProvider.make fails with InvalidConfiguration and says so.

Quick start

You need an SDK key: in app.optimizely.com, open your project's Settings → Environments and copy the key for the environment. Each environment has its own key.

val flags: ZLayer[Any, Throwable | FeatureFlagError, FeatureFlags] =
  ZLayer.scoped {
    for
      sdkKey   <- ZIO.attempt(sys.env("OPTIMIZELY_SDK_KEY"))
      provider <- OptimizelyProvider.make(sdkKey) // validates the key and starts fetching the datafile
      env      <- FeatureFlags.fromProviderAsync(provider).build
    yield env.get[FeatureFlags]
  }

// Optimizely decides per user: every evaluation needs a targeting key
val checkout: ZIO[FeatureFlags, FeatureFlagError, Boolean] =
  FeatureFlags.boolean("new-checkout", default = false, EvaluationContext("user-42").withAttribute("plan", "pro"))

Every evaluation needs a targeting key

Optimizely decides per user, so it needs a user id. The provider uses the context's targeting key. Without one, every evaluation answers TARGETING_KEY_MISSING: the typed tier fails with TargetingKeyMissing, and the total tier serves your default. Set it per call, or once with withContext or the global context.

What happens at startup

  1. OptimizelyProvider.make(sdkKey) checks the key, builds the Optimizely client, and the client starts fetching the datafile from https://cdn.optimizely.com/datafiles/<sdkKey>.json.
  2. When the provider is initialized, it waits up to initWait (25 s) for the datafile. That is shorter than initTimeout, so a datafile that never loads is reported with the provider's own error rather than a generic timeout. Once it has one, the provider is READY.
  3. FeatureFlags.fromProviderAsync does not wait for that. Until the provider is ready, evaluations fail with ProviderNotReady. If it is not ready within initTimeout (30 s by default), the status becomes Fatal and the provider is shut down. Fatal is final: install a new provider with setProvider, or rebuild the layer.

The key must be 6 to 128 characters of A-Z, a-z, 0-9, _ or -, with no whitespace, and not an obvious placeholder such as YOUR_SDK_KEY or changeme. Anything else fails with InvalidConfiguration before the SDK is touched.

The provider owns a polling thread and an HTTP client. Registering it with a FeatureFlags layer hands those over: the layer shuts the provider down when it closes. If the provider might never reach a layer, create it with OptimizelyProvider.scoped or OptimizelyProvider.layer, which shut it down with their scope.

Fail fast instead

If some flags guard money or safety, serving defaults while Optimizely starts may be wrong. Initialize synchronously: the layer build then waits for the datafile, and fails if it does not arrive within initTimeout.

def failFast(provider: OptimizelyFeatureProvider): ZLayer[Any, Throwable, FeatureFlags] =
  FeatureFlags.fromProvider(provider, FeatureFlagsConfig().withInitTimeout(15.seconds)) // build fails if not ready

With async initialization, keep traffic away until the provider is ready by wiring its status into your readiness check:

val ready: URIO[FeatureFlags, Boolean] =
  FeatureFlags.providerStatus.map {
    case ProviderStatus.Ready | ProviderStatus.Stale => true
    case _                                           => false
  }

How flags are read

You ask for You get
boolean whether the flag is enabled for this user
string the flag variable named value; if there is none, the variation key; otherwise your default
int, long, double, obj the flag variable named value (a JSON variable for obj); if there is none, your default

A variable that exists with another type is an error, not your default: reading a double variable as int, or the string "42" as int, fails with TypeMismatch. Only exact widenings are accepted: an integer variable reads as long or double. A long is read from an integer variable, since Optimizely has no 64-bit one; values beyond the int range read as long but not as int. To read a variable with another name, put its name in the context attribute openfeature.variableKey (OptimizelyFeatureProvider.VariableKeyAttribute):

// A string flag reads the variable named "value"; this call reads the variable "headline" instead
val headline: ZIO[FeatureFlags, FeatureFlagError, String] =
  FeatureFlags.string(
    "homepage",
    default = "Welcome",
    EvaluationContext("user-42").withAttribute(OptimizelyFeatureProvider.VariableKeyAttribute, "headline")
  )

The reason is TargetingMatch when the user got a variation and Default otherwise, and the variation key is the variant. The resolution's metadata carries optimizely.flagKey and optimizely.ruleKey.

Error code When
TargetingKeyMissing the context has no targeting key
FlagNotFound the datafile has no flag with this key
ProviderNotReady the datafile has not loaded yet, or the provider was shut down
TypeMismatch the variable exists but has another type (for example a double read as int)
General the Optimizely SDK failed to decide, including a CMAB experiment whose decision could not be fetched

CMAB experiments (contextual multi-armed bandits) are supported: the provider wires Optimizely's CMAB service, as the SDK's own factory does. On a cache miss, deciding a CMAB flag makes a blocking call to Optimizely's CMAB endpoint (10 seconds and one retry by default), so give such flags an evaluationTimeout you can afford, or put the provider behind a CircuitBreakerProvider.

Context attributes go to Optimizely as audience attributes. Strings, booleans and numbers pass as they are, and an Instant becomes its ISO-8601 string. Lists and structures are left out, because no audience condition can match them.

Tracking

FeatureFlags.track sends a conversion to Optimizely for the context's targeting key, with the context's attributes. The tracking details' attributes become event tags (normalized like audience attributes), and the details' value becomes the value tag. Optimizely's revenue metric expects integer cents: pass it as an integer attribute named revenue; it is never derived from value.

track fails with a FeatureFlagError when the provider is not ready, when the context has no targeting key, or when the datafile defines no event with that key (Optimizely itself would drop it with only an INFO log).

Events

Experiment results in Optimizely come from events the SDK sends from your process: an impression for each decision (rollout decisions too, when the project's "send flag decisions" setting is on) and a conversion for each track. A provider built with make, scoped or layer sends them by default. Each event carries:

  • the user id, which is the context's targeting key;
  • the audience attributes derived from the evaluation context (see above);
  • the decision (flag, rule, variation) or the conversion (event key, tags, value, revenue).

They go to https://logx.optimizely.com in batches of 10, or after 30 seconds, whichever comes first, from a background thread, over the same HTTP client the provider uses for CMAB. Shutting the provider down sends what is queued, waiting up to 3 seconds for the queue and then up to 3 seconds for the requests in flight; events still queued after that are dropped.

To send nothing, as earlier releases did, disable them:

val silent: IO[FeatureFlagError.InvalidConfiguration, OptimizelyFeatureProvider] =
  OptimizelyProvider.make(
    OptimizelyProviderConfig(
      sdkKey = "abcd1234efgh5678",
      events = OptimizelyEvents.Disabled // no impressions or conversions leave the process
    )
  )

OptimizelyEvents.Batched(batchSize, flushInterval, closeTimeout) tunes the batching instead. closeTimeout can be at most 4 seconds, because it is waited out twice on shutdown and the provider's shutdown is bounded at 10 seconds. A provider from fromOptimizelyClient sends events however its client was built.

Configuration

val tuned: IO[FeatureFlagError.InvalidConfiguration, OptimizelyFeatureProvider] =
  OptimizelyProvider.make(
    OptimizelyProviderConfig(
      sdkKey = "abcd1234efgh5678",
      pollingInterval = Some(java.time.Duration.ofMinutes(1)), // also turns on the staleness watchdog
      staleAfter = Some(java.time.Duration.ofMinutes(5))       // STALE after 5 min without a successful fetch
    )
  )
OptimizelyProviderConfig field Default Meaning
sdkKey required your environment's SDK key
datafileUrl the Optimizely CDN where to fetch the datafile, such as a self-hosted Agent
initWait 25 s how long initialization waits for the first datafile; keep it below initTimeout
pollingInterval the SDK's, 5 min how often the datafile is fetched again; setting it also turns on the staleness watchdog
blockingTimeout the SDK's, 10 s longest the SDK blocks waiting for a config
staleAfter 3 × pollingInterval with the watchdog on, how long without a successful fetch before the provider is STALE
events OptimizelyEvents.Batched() send impressions and conversions to Optimizely, or OptimizelyEvents.Disabled; see Events

OptimizelyProvider.make(sdkKey, datafileUrl, initWait) is a shortcut for the first three.

Self-hosted Optimizely Agent

If you run Optimizely Agent inside your network, fetch the datafile from it:

val selfHosted: IO[FeatureFlagError.InvalidConfiguration, OptimizelyFeatureProvider] =
  OptimizelyProvider.make(
    sdkKey = "abcd1234efgh5678",
    datafileUrl = "https://flags.internal.example.com/datafile.json"
  )

The URL is checked like the key: it must parse, use http or https, and name a host.

Datafile updates

The SDK fetches the datafile again every pollingInterval. When a new revision arrives, the provider emits ConfigurationChanged. Its set of changed flags is empty, because the SDK does not say which flags changed: use it as a signal, and read the flags again.

val logChanges: URIO[FeatureFlags, UIO[Unit]] =
  FeatureFlags.onConfigurationChanged((_, _) => ZIO.logInfo("Optimizely datafile changed"))

A fetch that fails leaves the last datafile in use, and nothing tells you about it unless the staleness watchdog is on. It runs only when you set pollingInterval. It marks the provider STALE once fetches have failed for staleAfter, and READY again when a fetch succeeds. A stale provider keeps answering from the last datafile.

With a circuit breaker

Decisions are local, so a slow Optimizely CDN does not slow evaluations down once the datafile is loaded. The provider's own state is what matters. CircuitBreakerProvider from Extras reads it, and opens while the provider reports ERROR or FATAL, or, by default, STALE. Put a fallback behind it for the flags that must have a sensible value during an outage:

def resilient(provider: OptimizelyFeatureProvider): ZLayer[Any, Throwable | FeatureFlagError, FeatureFlags] =
  ZLayer.fromZIO(CircuitBreakerProvider.make(provider, CircuitBreakerProviderConfig(failureThreshold = 5))).flatMap {
    env =>
      val chain = FeatureFlags.multiProvider(
        List(env.get[CircuitBreakerProvider], EnvVarProvider()), // env vars answer while Optimizely is out
        MultiProviderStrategy.firstSuccessful
      )
      FeatureFlags.fromProvider(chain, FeatureFlagsConfig().withAsyncInit)
  }

The fallback hides Optimizely outages from your callers, so watch Optimizely separately: a flag that falls back looks like any other answer.

Choosing a setup

Async (quick start) Sync (fail fast) With a fallback
Starts while Optimizely is down yes; evaluations fail until it is ready no, the build fails yes; the fallback answers
During an outage after startup the last datafile keeps answering the last datafile keeps answering the last datafile, or the fallback while the breaker is open
Good for experiments, UI changes flags that guard money or safety a few flags whose default would be wrong

What to watch

Signal Likely cause
status Fatal soon after start, or the sync build fails wrong SDK key, or the datafile URL is unreachable
status Stale (watchdog on) datafile fetches have been failing for staleAfter
resolutions with TargetingKeyMissing a code path evaluates without a user id
resolutions with FlagNotFound the flag key is misspelled, or the flag is missing from this environment

Bring your own client

If you already build and manage an Optimizely client, wrap it. The provider then never closes the client. Build it with a CmabService (as OptimizelyFactory does) if your project uses CMAB experiments:

def fromClient(client: com.optimizely.ab.Optimizely): UIO[OptimizelyFeatureProvider] =
  OptimizelyProvider.fromOptimizelyClient(client) // you keep ownership: shutdown does not close the client

Testing

Test your application's flag logic against TestFeatureProvider, with no Optimizely key or network; see Testkit. To check the Optimizely wiring itself, point datafileUrl at a local HTTP stub that serves a datafile; Testing real providers covers fault-testing a real provider.