Skip to content

Hooks

A hook runs your code around every flag evaluation: to log it, time it, audit it, or add context to it. Hooks are plain ZIO values, written once and registered where they should apply.

One evaluation through three hooks: before runs api, client, invocation in order; the provider is called; after and finallyAfter run in reverse order. On the error path, the error stage runs instead of after.

Writing a hook

Extend FeatureHook and override the stages you need. Every stage has a no-op default.

val audit: FeatureHook = new FeatureHook:
  override def after[A](ctx: HookContext, details: FlagResolution[A], hints: HookHints): UIO[Unit] =
    ZIO.logInfo(s"${ctx.evaluationContext.targetingKey.getOrElse("anonymous")} got ${ctx.flagKey} = ${details.value}")

  override def error(ctx: HookContext, error: FeatureFlagError, hints: HookHints): UIO[Unit] =
    ZIO.logWarning(s"${ctx.flagKey} failed: ${error.message}")
Stage Runs Receives
before before the provider is called the context so far; may return extra context
after when the provider answered with a value and no error code the FlagResolution
error when the evaluation failed or the provider answered with an error code the FeatureFlagError
finallyAfter always, last Some(resolution) if the provider answered, otherwise None

Every stage also receives the HookContext (below) and the evaluation's HookHints.

Registering hooks

val register: ZIO[FeatureFlags, Nothing, Unit] =
  for
    _ <- FeatureFlags.addHook(audit)                                            // client: this FeatureFlags instance
    _ <- FeatureFlags.addHooks(List(FeatureHook.logging()))                     // several at once, run in list order
    _ <- FeatureFlags.addZioApiHook(FeatureHook.metrics((_, _, _) => ZIO.unit)) // runs before client hooks
  yield ()

// Invocation: hooks and hints for one call only
val once: ZIO[FeatureFlags, FeatureFlagError, FlagResolution[Boolean]] =
  FeatureFlags.booleanDetails(
    "new-ui",
    false,
    EvaluationContext.empty,
    EvaluationOptions(audit).withHint("requestId", "r-17")
  )

// At construction, before the first evaluation
val config: FeatureFlagsConfig = FeatureFlagsConfig().withHook(audit)
Level Register with Applies to
ZIO API addZioApiHook, addZioApiHooks every evaluation of this instance, ahead of client hooks; a FeatureFlagRegistry copies them to all its clients
Client addHook, addHooks, FeatureFlagsConfig().withHook(...) / withHooks(...) every evaluation of this instance
Invocation EvaluationOptions(hooks*), .withHook, .withHooks one call, through its *Details method
Java API addApiHook(javaHook) a dev.openfeature.sdk.Hook, run by the Java SDK
Provider the provider itself run by the Java SDK

FeatureFlags.hooks and zioApiHooks list what is registered; clearHooks, clearZioApiHooks and clearApiHooks remove it. The Java API is shared by every instance on the same OpenFeatureAPI, so clearApiHooks from a shared instance also clears its siblings' API hooks, and logs a warning when it does.

val manage: ZIO[FeatureFlags, Nothing, List[FeatureHook]] =
  for
    client <- FeatureFlags.hooks         // the client hooks, in order
    api    <- FeatureFlags.zioApiHooks   // the ZIO API hooks
    _      <- FeatureFlags.clearHooks
    _      <- FeatureFlags.clearZioApiHooks
    _      <- FeatureFlags.clearApiHooks // the Java SDK hooks
  yield api ++ client

Hooks written for the Java SDK

Hooks from the Java ecosystem can be reused. They run inside the SDK call, together with the provider's own hooks, so from the ZIO hooks' point of view they are part of "calling the provider".

// A hook written against the OpenFeature Java SDK runs inside the SDK call
val javaHook: dev.openfeature.sdk.Hook[Object] = new dev.openfeature.sdk.Hook[Object] {}

val registerJava: ZIO[FeatureFlags, Nothing, Unit] = FeatureFlags.addApiHook(javaHook)

Order

  • before runs ZIO API hooks, then client hooks, then invocation hooks, each in the order they were added.
  • The provider is called. Java API hooks and provider hooks run here, inside the SDK.
  • after, error and finallyAfter run in the reverse order: invocation, client, ZIO API.

So the first hook to see an evaluation is the last to see its outcome, like nested try / finally blocks.

When each stage runs

  • A clean answer runs after, then finallyAfter.
  • An answer that carries an error code (FLAG_NOT_FOUND, TYPE_MISMATCH, ...) runs error, not after. This holds for both the typed and the total tier: booleanOrDefault still serves your default, and the hooks still see the error.
  • A failure without an answer (provider not ready, timeout, exception) runs error, and finallyAfter gets None.
  • Hooks run before the provider status is checked, so an evaluation that fails because the provider is not ready still runs before, error and finallyAfter.
  • Hooks also run for values served by a transaction override or its cache.

Stages return UIO: a hook cannot fail an evaluation, and a misbehaving logger never takes your flags down. Handle anything that may fail inside the hook:

// Stages return UIO: a hook cannot fail an evaluation. Handle anything that may fail inside it.
def safe(send: String => Task[Unit]): FeatureHook = new FeatureHook:
  override def after[A](ctx: HookContext, details: FlagResolution[A], hints: HookHints): UIO[Unit] =
    send(ctx.flagKey).ignoreLogged

A defect (a thrown exception) is different. A defect in before or after skips the rest of that stage, runs error with a ProviderError, runs finallyAfter, and then fails the evaluation with that defect. After an after defect, finallyAfter receives your default with reason Error and code General.

Adding context in before

before may return extra context. It is merged on top of the evaluation's context, so it wins over every context level. Each later hook sees the context as changed by the earlier ones.

// `before` may return extra context; it is merged on top of everything else
def stampRegion(region: String): FeatureHook = new FeatureHook:
  override def before(ctx: HookContext, hints: HookHints): UIO[Option[EvaluationContext]] =
    ZIO.some(EvaluationContext.empty.withAttribute("region", region))

To add context from somewhere the application already holds it, without letting it override what callers pass explicitly, use a context source instead.

Passing data between stages

HookData is a small store private to one hook during one evaluation. Write in before, read in a later stage. Use a TypedKey to keep the value's type. This holds inside FeatureHook.compose too: each hook in a composite gets its own HookData per evaluation, even when the composite is registered once and shared, or lists the same hook twice.

// HookData is private to one hook for one evaluation: set it in `before`, read it in a later stage
val timer: FeatureHook = new FeatureHook:
  private val startedAt = TypedKey[Long]("timer.startedAt")

  override def before(ctx: HookContext, hints: HookHints): UIO[Option[EvaluationContext]] =
    Clock.nanoTime.map(now => ctx.hookData.set(startedAt, now)).as(None)

  override def finallyAfter(ctx: HookContext, details: Option[FlagResolution[_]], hints: HookHints): UIO[Unit] =
    Clock.nanoTime.flatMap { now =>
      val took = ctx.hookData.get(startedAt).fold(0L)(now - _)
      ZIO.logDebug(s"${ctx.flagKey} took ${took / 1000} µs")
    }

HookHints are read-only values the caller hands to every hook of one evaluation, through EvaluationOptions:

// Hints are read-only values the caller hands to every hook of one evaluation
val requestIdLogger: FeatureHook = new FeatureHook:
  override def after[A](ctx: HookContext, details: FlagResolution[A], hints: HookHints): UIO[Unit] =
    ZIO.logInfo(s"[${hints.getOrElse[String]("requestId", "-")}] ${ctx.flagKey} = ${details.value}")

Pass them with EvaluationOptions(...).withHint("requestId", "r-17"), as in the registration example above.

Filtering by flag type

A hook runs only for evaluations whose type is in its supportedFlagTypes (all types by default). The type is the one the provider is asked for, so a string-backed enum counts as String.

// Only runs for String flags (and string-backed custom types such as enums)
val stringsOnly: FeatureHook = new FeatureHook:
  override def supportedFlagTypes: Set[FlagValueType] = Set(FlagValueType.String)

  override def after[A](ctx: HookContext, details: FlagResolution[A], hints: HookHints): UIO[Unit] =
    ZIO.logInfo(s"variant ${details.value} for ${ctx.flagKey}")

FlagValueType has Boolean, String, Int, Long, Double and Object.

Built-in hooks

val logging: FeatureHook = FeatureHook.logging(logBefore = false, logAfter = true, logError = true)

val structured: FeatureHook = FeatureHook.structuredLogging(
  afterLevel = Some(LogLevel.Info),
  logContext = true,
  redactKeys = Set("email") // logged as [REDACTED]
)

val metrics: FeatureHook = FeatureHook.metrics { (flagKey, duration, success) =>
  ZIO.logInfo(s"$flagKey took ${duration.toMillis} ms (success = $success)")
}

val detailedMetrics: FeatureHook = FeatureHook.metricsDetailed(
  onSuccess = (ctx, resolution, duration) => ZIO.logInfo(s"${ctx.flagKey} -> ${resolution.reason} in $duration"),
  onError = (ctx, error, duration) => ZIO.logWarning(s"${ctx.flagKey} failed after $duration: ${error.message}")
)

// Logs a warning when the context lacks these; it never fails the evaluation
val validator: FeatureHook = FeatureHook.contextValidator(
  requireTargetingKey = true,
  requiredAttributes = List("plan")
)
Hook What it does
FeatureHook.logging(logBefore, logAfter, logError) plain log lines: before at debug (off by default), after at info, error at error
FeatureHook.structuredLogging(beforeLevel, afterLevel, errorLevel, logContext, redactKeys) log lines with annotations: flag.key, flag.type, flag.provider, flag.domain, flag.value, flag.reason, flag.variant, flag.duration_ms, flag.error, and with logContext the context, minus redactKeys
FeatureHook.metrics((key, duration, success) => ...) calls you once per evaluation with its duration
FeatureHook.metricsDetailed(onSuccess, onError) the same, with the full HookContext and resolution or error, to build metric tags
FeatureHook.contextValidator(requireTargetingKey, requiredAttributes) logs a warning when the targeting key or an attribute is missing; never fails the evaluation
FeatureHook.noop does nothing
FeatureHook.compose(hooks) one hook that runs several, in the order above

HookContext

Field Meaning
flagKey the flag being evaluated
flagType the FlagValueType the provider is asked for
defaultValue the caller's default, as the caller's type (a custom type, not its wire value)
evaluationContext the merged context, including earlier before additions
clientMetadata the client's domain and version
providerMetadata the provider's name and version
hookData this hook's HookData for this evaluation

Keep hooks fast

Hooks run on the calling fiber, in sequence, on every evaluation. Keep them in memory; fork slow work (such as sending analytics) with .forkDaemon. An evaluation with no applicable hooks skips the hook machinery entirely.