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.
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¶
OptimizelyProvider.make(sdkKey)checks the key, builds the Optimizely client, and the client starts fetching the datafile fromhttps://cdn.optimizely.com/datafiles/<sdkKey>.json.- When the provider is initialized, it waits up to
initWait(25 s) for the datafile. That is shorter thaninitTimeout, 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 isREADY. FeatureFlags.fromProviderAsyncdoes not wait for that. Until the provider is ready, evaluations fail withProviderNotReady. If it is not ready withininitTimeout(30 s by default), the status becomesFataland the provider is shut down.Fatalis final: install a new provider withsetProvider, 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.