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.
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
Readyevent; a laterReadymeans 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
withContextblock 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.