|
| 1 | +package zio.openfeature.optimizely |
| 2 | + |
| 3 | +import com.optimizely.ab.Optimizely |
| 4 | +import com.optimizely.ab.optimizelydecision.OptimizelyDecision |
| 5 | +import dev.openfeature.sdk.{ |
| 6 | + EvaluationContext => OFEvaluationContext, |
| 7 | + EventProvider, |
| 8 | + ImmutableMetadata, |
| 9 | + Metadata, |
| 10 | + ProviderEvaluation, |
| 11 | + ProviderEventDetails, |
| 12 | + ProviderState, |
| 13 | + Reason, |
| 14 | + Structure, |
| 15 | + Value |
| 16 | +} |
| 17 | +import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicReference} |
| 18 | +import java.util.concurrent.{CountDownLatch, TimeUnit} |
| 19 | +import scala.jdk.CollectionConverters._ |
| 20 | +import scala.util.Try |
| 21 | + |
| 22 | +/** OpenFeature `FeatureProvider` implementation that delegates to the Optimizely Java SDK. |
| 23 | + * |
| 24 | + * Construction is intentionally simple: take an already-configured `Optimizely` client and adapt its decision API to |
| 25 | + * OpenFeature's evaluation API. The Optimizely client is responsible for its own datafile polling, error handling, and |
| 26 | + * event dispatch; this class translates between the two worlds. |
| 27 | + * |
| 28 | + * '''Lifecycle notes:''' |
| 29 | + * - On `initialize()` we register an Optimizely `UpdateConfigNotification` handler. The first time it fires |
| 30 | + * (datafile loaded), we count down an internal latch so the OpenFeature `setProviderAndWait` returns cleanly. |
| 31 | + * Subsequent fires emit OpenFeature `PROVIDER_CONFIGURATION_CHANGED` events. |
| 32 | + * - If the datafile never arrives within `initWait`, `initialize()` throws — propagated by the OpenFeature SDK as a |
| 33 | + * failed init. |
| 34 | + * - `shutdown()` removes the notification handler and closes the underlying client. |
| 35 | + * |
| 36 | + * '''Decision mapping:''' |
| 37 | + * - Boolean: returns `decision.getEnabled`. |
| 38 | + * - String: returns the variable `variableKey` (default `"value"`) from `decision.getVariables`, falling back to |
| 39 | + * `decision.getVariationKey`, then to the supplied default. |
| 40 | + * - Integer / Double / Object: extracts the variable `variableKey` from `decision.getVariables` (typed). |
| 41 | + * |
| 42 | + * The convention of looking up a variable named `"value"` (overridable via an `"openfeature.variableKey"` attribute on |
| 43 | + * the evaluation context) mirrors what the OpenFeature contrib Optimizely provider does, so apps switching between |
| 44 | + * providers see consistent behaviour. |
| 45 | + */ |
| 46 | +final class OptimizelyFeatureProvider private[optimizely] ( |
| 47 | + optimizely: Optimizely, |
| 48 | + initWait: java.time.Duration, |
| 49 | + closeOnShutdown: Boolean |
| 50 | +) extends EventProvider { |
| 51 | + |
| 52 | + // Public construction is via the OptimizelyProvider factory in this package — the constructor is private to keep |
| 53 | + // lifecycle invariants (single initialize, registered handler) intact. |
| 54 | + |
| 55 | + private val stateRef = new AtomicReference[ProviderState](ProviderState.NOT_READY) |
| 56 | + private val initialized = new AtomicBoolean(false) |
| 57 | + private val notificationHandle = new AtomicInteger(-1) |
| 58 | + private val initLatch = new CountDownLatch(1) |
| 59 | + |
| 60 | + @scala.annotation.nowarn("msg=deprecated") |
| 61 | + override def getMetadata: Metadata = new Metadata { |
| 62 | + override def getName: String = OptimizelyFeatureProvider.Name |
| 63 | + } |
| 64 | + |
| 65 | + @scala.annotation.nowarn("msg=deprecated") |
| 66 | + override def getState: ProviderState = stateRef.get() |
| 67 | + |
| 68 | + override def initialize(ctx: OFEvaluationContext): Unit = { |
| 69 | + if (!initialized.compareAndSet(false, true)) return |
| 70 | + |
| 71 | + val handlerId = optimizely.addUpdateConfigNotificationHandler { _ => |
| 72 | + // The latch is single-use; subsequent updates are CONFIGURATION_CHANGED only. |
| 73 | + initLatch.countDown() |
| 74 | + emitProviderConfigurationChanged(ProviderEventDetails.builder().build()) |
| 75 | + } |
| 76 | + // Optimizely's NotificationManager returns a non-positive id when registration fails (e.g. a duplicate handler |
| 77 | + // is already present). Without the handler we'd silently never count down the latch via the datafile-update |
| 78 | + // path and would always wait the full `initWait` window. Fail fast instead. |
| 79 | + if (handlerId <= 0) { |
| 80 | + stateRef.set(ProviderState.ERROR) |
| 81 | + throw new RuntimeException( |
| 82 | + s"Optimizely datafile update handler registration failed (returned id=$handlerId); cannot drive init" |
| 83 | + ) |
| 84 | + } |
| 85 | + notificationHandle.set(handlerId) |
| 86 | + |
| 87 | + if (optimizely.isValid) initLatch.countDown() |
| 88 | + |
| 89 | + if (!initLatch.await(initWait.toMillis, TimeUnit.MILLISECONDS)) { |
| 90 | + stateRef.set(ProviderState.ERROR) |
| 91 | + throw new RuntimeException( |
| 92 | + s"Optimizely datafile did not load within $initWait; check the SDK key and network reachability" |
| 93 | + ) |
| 94 | + } |
| 95 | + |
| 96 | + if (optimizely.isValid) stateRef.set(ProviderState.READY) |
| 97 | + else { |
| 98 | + stateRef.set(ProviderState.ERROR) |
| 99 | + throw new RuntimeException( |
| 100 | + "Optimizely client reported invalid configuration after datafile load (possible auth or parse failure)" |
| 101 | + ) |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + override def shutdown(): Unit = { |
| 106 | + val handle = notificationHandle.getAndSet(-1) |
| 107 | + if (handle > 0) { |
| 108 | + // Removing the handler is best-effort; if the notification center is already shut down we ignore. |
| 109 | + Try(optimizely.getNotificationCenter.removeNotificationListener(handle)) |
| 110 | + () |
| 111 | + } |
| 112 | + if (closeOnShutdown) Try(optimizely.close()) |
| 113 | + stateRef.set(ProviderState.NOT_READY) |
| 114 | + } |
| 115 | + |
| 116 | + override def getBooleanEvaluation( |
| 117 | + key: String, |
| 118 | + defaultValue: java.lang.Boolean, |
| 119 | + ctx: OFEvaluationContext |
| 120 | + ): ProviderEvaluation[java.lang.Boolean] = |
| 121 | + decide(key, ctx) match { |
| 122 | + case Right(d) => |
| 123 | + ProviderEvaluation |
| 124 | + .builder[java.lang.Boolean]() |
| 125 | + .value(java.lang.Boolean.valueOf(d.getEnabled)) |
| 126 | + .variant(d.getVariationKey) |
| 127 | + .reason(deriveReason(d)) |
| 128 | + .flagMetadata(metadataFrom(d)) |
| 129 | + .build() |
| 130 | + case Left(err) => failingEvaluation(defaultValue, err) |
| 131 | + } |
| 132 | + |
| 133 | + override def getStringEvaluation( |
| 134 | + key: String, |
| 135 | + defaultValue: String, |
| 136 | + ctx: OFEvaluationContext |
| 137 | + ): ProviderEvaluation[String] = |
| 138 | + decide(key, ctx) match { |
| 139 | + case Right(d) => |
| 140 | + val variableKey = OptimizelyFeatureProvider.variableKey(ctx) |
| 141 | + val variable = readVariable[String](d, variableKey, classOf[String]) |
| 142 | + // Fallback order: typed variable → variation key (still a meaningful Optimizely-driven answer) → OF default. |
| 143 | + val (value, usedDefault) = variable match { |
| 144 | + case Some(v) => (v, false) |
| 145 | + case None => |
| 146 | + Option(d.getVariationKey) match { |
| 147 | + case Some(vk) => (vk, false) |
| 148 | + case None => (defaultValue, true) |
| 149 | + } |
| 150 | + } |
| 151 | + ProviderEvaluation |
| 152 | + .builder[String]() |
| 153 | + .value(value) |
| 154 | + .variant(d.getVariationKey) |
| 155 | + .reason(if (usedDefault) Reason.DEFAULT.name() else deriveReason(d)) |
| 156 | + .flagMetadata(metadataFrom(d)) |
| 157 | + .build() |
| 158 | + case Left(err) => failingEvaluation(defaultValue, err) |
| 159 | + } |
| 160 | + |
| 161 | + override def getIntegerEvaluation( |
| 162 | + key: String, |
| 163 | + defaultValue: java.lang.Integer, |
| 164 | + ctx: OFEvaluationContext |
| 165 | + ): ProviderEvaluation[java.lang.Integer] = |
| 166 | + typedEvaluation[java.lang.Integer](key, defaultValue, ctx, classOf[java.lang.Integer]) |
| 167 | + |
| 168 | + override def getDoubleEvaluation( |
| 169 | + key: String, |
| 170 | + defaultValue: java.lang.Double, |
| 171 | + ctx: OFEvaluationContext |
| 172 | + ): ProviderEvaluation[java.lang.Double] = |
| 173 | + typedEvaluation[java.lang.Double](key, defaultValue, ctx, classOf[java.lang.Double]) |
| 174 | + |
| 175 | + /** Shared scaffold for Integer/Double evaluations: extract the named variable, fall back to the OF default with a |
| 176 | + * `DEFAULT` reason if the variable is missing. The Optimizely SDK throws `JsonParseException` from `getValue` when |
| 177 | + * the key is absent, so we wrap in `Try` and treat any failure as a missing variable. |
| 178 | + */ |
| 179 | + private def typedEvaluation[A]( |
| 180 | + key: String, |
| 181 | + defaultValue: A, |
| 182 | + ctx: OFEvaluationContext, |
| 183 | + clazz: Class[A] |
| 184 | + ): ProviderEvaluation[A] = |
| 185 | + decide(key, ctx) match { |
| 186 | + case Right(d) => |
| 187 | + val variableKey = OptimizelyFeatureProvider.variableKey(ctx) |
| 188 | + val variable = readVariable[A](d, variableKey, clazz) |
| 189 | + val (value, usedDefault) = variable match { |
| 190 | + case Some(v) => (v, false) |
| 191 | + case None => (defaultValue, true) |
| 192 | + } |
| 193 | + ProviderEvaluation |
| 194 | + .builder[A]() |
| 195 | + .value(value) |
| 196 | + .variant(d.getVariationKey) |
| 197 | + .reason(if (usedDefault) Reason.DEFAULT.name() else deriveReason(d)) |
| 198 | + .flagMetadata(metadataFrom(d)) |
| 199 | + .build() |
| 200 | + case Left(err) => failingEvaluation(defaultValue, err) |
| 201 | + } |
| 202 | + |
| 203 | + /** Reads the named variable as type `A`, swallowing the JSON-parse failures Optimizely throws when the key is absent. |
| 204 | + * `clazz` tells the Optimizely SDK which Java type to extract. |
| 205 | + */ |
| 206 | + private def readVariable[A](d: OptimizelyDecision, name: String, clazz: Class[A]): Option[A] = |
| 207 | + Try(d.getVariables.getValue(name, clazz)).toOption.flatMap(Option(_)) |
| 208 | + |
| 209 | + override def getObjectEvaluation( |
| 210 | + key: String, |
| 211 | + defaultValue: Value, |
| 212 | + ctx: OFEvaluationContext |
| 213 | + ): ProviderEvaluation[Value] = |
| 214 | + decide(key, ctx) match { |
| 215 | + case Right(d) => |
| 216 | + val map = Option(d.getVariables).map(_.toMap).getOrElse(java.util.Collections.emptyMap[String, Object]()) |
| 217 | + val value = new Value(Structure.mapToStructure(map)) |
| 218 | + ProviderEvaluation |
| 219 | + .builder[Value]() |
| 220 | + .value(value) |
| 221 | + .variant(d.getVariationKey) |
| 222 | + .reason(deriveReason(d)) |
| 223 | + .flagMetadata(metadataFrom(d)) |
| 224 | + .build() |
| 225 | + case Left(err) => failingEvaluation(defaultValue, err) |
| 226 | + } |
| 227 | + |
| 228 | + // Common decision pipeline. Returns Left with an error reason string when no decision is possible. |
| 229 | + private def decide(key: String, ctx: OFEvaluationContext): Either[String, OptimizelyDecision] = { |
| 230 | + val transformed = ContextTransformer.transform(ctx) |
| 231 | + if (transformed.userId.isEmpty) Left("TARGETING_KEY_MISSING") |
| 232 | + else if (!optimizely.isValid) Left("PROVIDER_NOT_READY") |
| 233 | + else |
| 234 | + Try(optimizely.createUserContext(transformed.userId, transformed.attributes).decide(key)).toEither.left |
| 235 | + .map(t => Option(t.getMessage).getOrElse(t.getClass.getSimpleName)) |
| 236 | + .flatMap { decision => |
| 237 | + if (isFlagNotFound(decision)) Left("FLAG_NOT_FOUND") |
| 238 | + else Right(decision) |
| 239 | + } |
| 240 | + } |
| 241 | + |
| 242 | + /** Identify a "flag not found" outcome from Optimizely's decision reasons. The Java SDK emits messages like `No flag |
| 243 | + * was found for key "..."` (via `DecisionMessage.FLAG_KEY_INVALID`); we match on a stable substring and the |
| 244 | + * `FLAG_KEY_INVALID` symbol so a future formatting tweak doesn't silently re-break this path. |
| 245 | + */ |
| 246 | + private def isFlagNotFound(d: OptimizelyDecision): Boolean = |
| 247 | + if (Option(d.getVariationKey).isDefined) false |
| 248 | + else { |
| 249 | + val errs = Option(d.getReasons).map(_.asScala.toList).getOrElse(Nil) |
| 250 | + errs.exists { reason => |
| 251 | + val lower = reason.toLowerCase |
| 252 | + lower.contains("no flag was found") || lower.contains("flag_key_invalid") |
| 253 | + } |
| 254 | + } |
| 255 | + |
| 256 | + private def deriveReason(d: OptimizelyDecision): String = { |
| 257 | + val errs = Option(d.getReasons).map(_.asScala.toList).getOrElse(Nil) |
| 258 | + if (errs.isEmpty && Option(d.getVariationKey).isDefined) Reason.TARGETING_MATCH.name() |
| 259 | + else if (Option(d.getVariationKey).isEmpty) Reason.DEFAULT.name() |
| 260 | + else Reason.TARGETING_MATCH.name() |
| 261 | + } |
| 262 | + |
| 263 | + private def metadataFrom(d: OptimizelyDecision): ImmutableMetadata = { |
| 264 | + val builder = ImmutableMetadata.builder() |
| 265 | + Option(d.getRuleKey).foreach(builder.addString("optimizely.ruleKey", _)) |
| 266 | + Option(d.getFlagKey).foreach(builder.addString("optimizely.flagKey", _)) |
| 267 | + builder.build() |
| 268 | + } |
| 269 | + |
| 270 | + private def failingEvaluation[A](defaultValue: A, errorCode: String): ProviderEvaluation[A] = { |
| 271 | + val mapped = errorCode match { |
| 272 | + case "TARGETING_KEY_MISSING" => dev.openfeature.sdk.ErrorCode.TARGETING_KEY_MISSING |
| 273 | + case "PROVIDER_NOT_READY" => dev.openfeature.sdk.ErrorCode.PROVIDER_NOT_READY |
| 274 | + case "FLAG_NOT_FOUND" => dev.openfeature.sdk.ErrorCode.FLAG_NOT_FOUND |
| 275 | + case _ => dev.openfeature.sdk.ErrorCode.GENERAL |
| 276 | + } |
| 277 | + ProviderEvaluation |
| 278 | + .builder[A]() |
| 279 | + .value(defaultValue) |
| 280 | + .reason(Reason.ERROR.name()) |
| 281 | + .errorCode(mapped) |
| 282 | + .errorMessage(errorCode) |
| 283 | + .build() |
| 284 | + } |
| 285 | +} |
| 286 | + |
| 287 | +object OptimizelyFeatureProvider { |
| 288 | + val Name: String = "Optimizely" |
| 289 | + |
| 290 | + /** Context attribute key callers can set to override which Optimizely variable is read for typed evaluations. */ |
| 291 | + val VariableKeyAttribute: String = "openfeature.variableKey" |
| 292 | + |
| 293 | + /** Default Optimizely variable name used by typed evaluations when the context doesn't override. */ |
| 294 | + val DefaultVariableKey: String = "value" |
| 295 | + |
| 296 | + private[optimizely] def variableKey(ctx: OFEvaluationContext): String = { |
| 297 | + val v = Option(ctx).flatMap(c => Option(c.getValue(VariableKeyAttribute))) |
| 298 | + v.flatMap(value => Option(value.asString())).filter(_.nonEmpty).getOrElse(DefaultVariableKey) |
| 299 | + } |
| 300 | +} |
0 commit comments