Skip to content

Lifecycle and events

A provider starts, becomes ready, may go stale or fail, and is shut down. FeatureFlags tracks this as a status, uses the status to decide whether an evaluation may run, and publishes the provider's events to your code.

Provider status: the provider starts NotReady and evaluations fail; a READY event makes it Ready; STALE and ERROR events move it to Stale or Error, where evaluations still run; READY brings it back; shutdown passes through ShuttingDown to NotReady. Fatal, reached by an init timeout before the first Ready or a fatal error, fails every evaluation until setProvider installs a new provider.

Statuses

ProviderStatus Meaning An evaluation
NotReady Starting, being swapped, or shut down fails with ProviderNotReady(NotReady)
Ready Serving runs
Stale Serving data that may be out of date runs
Error The provider reported an error it may recover from runs
Fatal The provider cannot recover fails with ProviderFatal
ShuttingDown shutdown is in progress fails with ProviderNotReady(ShuttingDown)

Error still evaluates on purpose: providers in that state usually keep serving their last good values, or answer with an error code of their own. status.canEvaluate is true for Ready and Stale, and status.isRecoverable is false only for Fatal.

What changes the status

Cause Change
The provider reports READY NotReady, Stale or Error → Ready
The provider reports STALE Ready → Stale
The provider reports ERROR NotReady, Ready or Stale → Error
The provider reports ERROR with code ProviderFatal any status except ShuttingDown → Fatal
Async start: initTimeout passes before the provider was ever ready NotReady or Error → Fatal
setProvider any → NotReady while it runs, then Ready (Error if neither the new nor the old provider can serve, or if the new provider is still initializing at initTimeout)
fromAcquireAsync switching from the fallback to the real provider none: the status stays Ready, and the fallback answers until the switch
shutdown any → ShuttingDown → NotReady

Fatal stays until setProvider installs a new provider. Events that arrive during a swap, or after a shutdown, do not change the status. How the start works, and how long it may take, is set on the factory.

Wait until the provider is ready

awaitReady(within) waits until the status is Ready or Stale, stops early on Fatal, and gives up after within. It returns the status at that moment, so it never fails. It does not poll, and any number of fibers can wait at once.

// A /ready probe: waits up to 5 seconds for Ready or Stale, returns early on Fatal
val readiness: URIO[FeatureFlags, Boolean] =
  ZIO.serviceWithZIO[FeatureFlags](_.awaitReady(5.seconds)).map(_.canEvaluate)

// The status right now, without waiting
val status: URIO[FeatureFlags, ProviderStatus] =
  FeatureFlags.providerStatus

Answer while the provider is not ready

With an async start, evaluations fail with ProviderNotReady until the provider is ready. Serve the default meanwhile:

// While the provider starts, serve the default instead of failing
val checkout: ZIO[FeatureFlags, FeatureFlagError, Boolean] =
  FeatureFlags.boolean("new-checkout", default = false).catchSome { case _: FeatureFlagError.ProviderNotReady =>
    ZIO.succeed(false)
  }

The *OrDefault calls do this for every error, see Evaluating flags.

React to events

Register a handler for the events you care about. Each registration returns an effect that unsubscribes it:

val watchProvider: URIO[FeatureFlags, UIO[Unit]] =
  for
    onReady  <- FeatureFlags.onProviderReady(meta => ZIO.logInfo(s"${meta.name} is ready"))
    onError  <- FeatureFlags.onProviderError((error, meta) => ZIO.logError(s"${meta.name}: ${error.getMessage}"))
    onStale  <- FeatureFlags.onProviderStale((reason, meta) => ZIO.logWarning(s"${meta.name} is stale: $reason"))
    onChange <- FeatureFlags.onConfigurationChanged((flags, _) => ZIO.logInfo(s"changed: ${flags.mkString(", ")}"))
    onAny    <- FeatureFlags.on(ProviderEventType.Error, event => ZIO.logDebug(s"event: $event"))
  yield onReady *> onError *> onStale *> onChange *> onAny // run it to unsubscribe all five
Handler Called with Also called at once if the status is
onProviderReady ProviderMetadata Ready
onProviderError the error and ProviderMetadata Error or Fatal
onProviderStale the reason and ProviderMetadata Stale
onConfigurationChanged the changed flag keys and ProviderMetadata never
on(eventType, handler) the whole ProviderEvent as the matching handler above
  • The subscription is in place before registration returns, so no later event is missed. An event that arrives while you register may reach the handler twice. Make handlers idempotent.
  • A handler that dies is logged, and keeps its subscription.
  • One provider initialization produces one Ready event; a later Ready means the provider recovered.
  • When the status already matches, the handler gets a payload that describes the current state, not the event that caused it.
  • A handler does not run inside the transaction or withContext block it was registered from, even though it may have been registered there: its evaluations see neither the transaction's overrides nor that fiber-local context.

Scoping a handler

Every registration returns its canceller, and the handler keeps running until you run it or the instance shuts down. To tie a handler to a scope, hand the canceller to acquireRelease:

// The handler is unsubscribed when the scope closes, even if the body fails
def readyWhileScoped(onReady: ProviderMetadata => UIO[Unit]): ZIO[FeatureFlags, Nothing, Unit] =
  ZIO.scoped {
    ZIO.acquireRelease(FeatureFlags.onProviderReady(onReady))(cancel => cancel) *>
      ZIO.logInfo("handling events") // the work that needs the handler goes here
  }

The same shape works for every on* method.

Read the event stream

FeatureFlags.events is a ZStream of every event. It ends when the instance shuts down.

val logEvents: ZIO[FeatureFlags & Scope, Nothing, Unit] =
  FeatureFlags.events
    .foreach {
      case ProviderEvent.Ready(meta, _)                   => ZIO.logInfo(s"$meta ready")
      case ProviderEvent.ConfigurationChanged(keys, _, _) => ZIO.logInfo(s"changed: $keys")
      case ProviderEvent.Stale(reason, _, _)              => ZIO.logWarning(s"stale: $reason")
      case ProviderEvent.Error(_, _, code, message, _)    => ZIO.logError(s"$code ${message.getOrElse("")}")
      case ProviderEvent.Reconnecting(_, _)               => ZIO.unit
    }
    .forkScoped // runs until the scope closes or the instance shuts down
    .unit
ProviderEvent Fields
Ready providerMetadata, eventMetadata
Error error, providerMetadata, errorCode, errorMessage, eventMetadata
Stale reason, providerMetadata, eventMetadata
ConfigurationChanged changedFlags, providerMetadata, eventMetadata
Reconnecting providerMetadata, eventMetadata

Every event has eventType (a ProviderEventType), metadata and eventMeta (the provider's extra FlagMetadata), and isError / isHealthy.

Note

Reconnecting exists for completeness with the specification, which defines it for client-side providers. The Java SDK has no such event, so a server-side provider never sends it.

Which bundled providers send events

Provider Sends
OptimizelyFeatureProvider, from OptimizelyProvider.make (Optimizely) READY and STALE when its datafile goes stale and recovers, CONFIGURATION_CHANGED on a new datafile
CircuitBreakerProvider (Extras) its own READY and STALE when the breaker changes, plus the wrapped provider's events if it sends any
CachingProvider (Extras) the wrapped provider's events, if it sends any
TestFeatureProvider (Testkit) whatever the test emits with emitEvent
HoconProvider, EnvVarProvider, DeferredProvider, IntegerWideningLongProvider nothing

The Java SDK itself sends READY or ERROR when a provider's initialization finishes, for every provider.

Track user actions

track sends an event, such as a conversion, to the provider, for experiments and analytics:

val purchase: ZIO[FeatureFlags, FeatureFlagError, Unit] =
  val user    = EvaluationContext("user-42")
  val details = TrackingEventDetails(99.99).withAttribute("currency", "EUR")
  FeatureFlags.track("checkout-completed") *>     // event name only
    FeatureFlags.track("purchase", user, details) // with targeting context and details
Call Sends
track(name) the event name
track(name, context) the event name and a targeting context
track(name, details) the event name and TrackingEventDetails
track(name, context, details) all three

The context is merged with the global, client and ambient context exactly as for an evaluation (see Evaluation context). TrackingEventDetails has an optional numeric value and attributes. Attribute values can be the plain types withAttribute accepts for a context, any AttributeValue, Options of these, and nested Lists and Maps; a null or None attribute is dropped. Any other type fails the call with FeatureFlagError.InvalidContext before anything is sent. A provider that does not support tracking ignores the call. A provider error fails the call with a FeatureFlagError.

Tracked events are not kept by the instance: their merged contexts routinely hold personal data. To assert on tracking in a test, use the testkit's TestFeatureProvider.trackedEvents, which lists what the provider received (see Testkit). FeatureFlags.trackedEvents is deprecated, and is empty except on testkit-built instances.

Shut down

// Also runs on its own when the layer's scope closes
val stop: URIO[FeatureFlags, Unit] = FeatureFlags.shutdown

Shutdown moves the status to ShuttingDown and then NotReady, and evaluations fail from then on. It clears the client hooks and ZIO API-level hooks, the global and client context and the tracked events, and ends the event stream and every handler. Whether it also stops the provider depends on who owns the API.