Evaluation context¶
An EvaluationContext tells the provider who or what a flag is being evaluated for: a targeting key (usually a
user or entity id) and attributes such as a plan or a country. Providers use it for targeting rules and percentage
rollouts.
You can set context at several levels, from the whole application down to a single call. Before each evaluation, the levels are merged into one context, and that is what the provider sees.
Creating a context¶
// A targeting key only
val user: EvaluationContext = EvaluationContext("user-123")
// ...plus attributes. Plain Scala values convert to AttributeValue on their own.
val premiumUser: EvaluationContext =
EvaluationContext("user-123")
.withAttribute("plan", "premium")
.withAttribute("age", 30)
.withAttribute("beta", true)
// The builder reads well when there are many attributes
val built: EvaluationContext =
EvaluationContext.builder
.targetingKey("user-123")
.attribute("plan", "premium")
.attribute("score", 4.5)
.build
// Sets the targeting key and two attributes: entityId = "org-7", entityType = "organization"
val org: EvaluationContext = EvaluationContext.forEntity("org-7", "organization")
withAttribute accepts plain Boolean, String, Int, Long, Double and java.time.Instant values. Other
helpers: withTargetingKey, withAttributes(...), withoutAttribute(key), and EvaluationContext.empty.
targetingKey is a reserved attribute name
The OpenFeature SDK stores the targeting key as the attribute targetingKey, so the two are one slot. An explicit
targeting key wins over an attribute of that name. With no targeting key, a string attribute named targetingKey
becomes the key and any other value is dropped. A context read back from the SDK, in a hook or in
TestFeatureProvider.evaluations, never has a targetingKey attribute.
Attribute types¶
AttributeValue |
Helper | Scala value |
|---|---|---|
BoolValue |
AttributeValue.bool |
Boolean |
StringValue |
AttributeValue.string |
String |
IntValue |
AttributeValue.int |
Int |
LongValue |
AttributeValue.long |
Long |
DoubleValue |
AttributeValue.double |
Double |
InstantValue |
AttributeValue.instant |
java.time.Instant |
ListValue |
AttributeValue.list |
a list of attribute values |
StructValue |
AttributeValue.struct |
a map of named attribute values |
val rich: EvaluationContext =
EvaluationContext("user-123").withAttributes(
"beta" -> AttributeValue.bool(true),
"plan" -> AttributeValue.string("premium"),
"seats" -> AttributeValue.int(25),
"orgId" -> AttributeValue.long(9007199254740993L), // see the note on longs below
"score" -> AttributeValue.double(4.5),
"signedUp" -> AttributeValue.instant(Instant.parse("2024-01-15T10:00:00Z")),
"roles" -> AttributeValue.list(AttributeValue.string("admin"), AttributeValue.string("editor")),
"address" -> AttributeValue.struct(
"country" -> AttributeValue.string("NL"),
"city" -> AttributeValue.string("Amsterdam")
)
)
Large longs
The OpenFeature Java SDK carries every number as a Double, so a Long above 2^53 loses precision on its way to
the provider. Pass IDs that large as strings.
Reading attributes back¶
val plan: Option[String] = premiumUser.getString("plan") // Some("premium")
val age: Option[Int] = premiumUser.getInt("age") // Some(30)
val raw: Option[AttributeValue] = premiumUser.get("beta") // Some(BoolValue(true))
getBoolean, getLong and getDouble work the same way. Each AttributeValue also has asString, asInt,
asList, asStruct and so on.
The targeting key¶
The targeting key identifies the subject of the evaluation. Providers typically hash it for percentage rollouts, so the same key always lands in the same bucket. Use a stable id: a user id, an account id, a device id. Avoid values that change, such as a session id, unless you want a user to move between buckets.
Context levels¶
| Level | Set with | Applies to |
|---|---|---|
| Global | FeatureFlags.setGlobalContext(ctx) |
every evaluation of every client |
| Transaction | FeatureFlags.transaction(context = ctx) |
evaluations inside the transaction |
| Client | FeatureFlags.setClientContext(ctx) |
evaluations of this FeatureFlags instance |
| Context source | FeatureFlagsConfig().withContextSource(source) |
every evaluation, pulled when it runs |
| Scoped | FeatureFlags.withContext(ctx) { ... } |
the block, on this fiber and the fibers it forks |
| Invocation | the ctx argument of a call |
that call |
val levels: ZIO[FeatureFlags, FeatureFlagError, Boolean] =
for
// Global: the whole application, every FeatureFlags client
_ <- FeatureFlags.setGlobalContext(EvaluationContext.empty.withAttribute("env", "production"))
// Client: this FeatureFlags instance
_ <- FeatureFlags.setClientContext(EvaluationContext.empty.withAttribute("service", "checkout"))
// Scoped: everything inside the block, on this fiber and fibers it forks
enabled <- FeatureFlags.withContext(EvaluationContext("user-123").withAttribute("plan", "free")) {
// Invocation: this one call
FeatureFlags.boolean("new-ui", false, EvaluationContext.empty.withAttribute("plan", "premium"))
}
yield enabled
// The provider sees: targetingKey = user-123, env = production, service = checkout, plan = premium
globalContext and clientContext read the current values back. A withContext inside another merges with the
outer one, so the inner block keeps the outer attributes it does not replace.
How levels merge¶
Levels are merged from the lowest to the highest:
global → transaction → client → context source → scoped → invocation
A higher level wins on every attribute both set, and on the targeting key if it sets one. Attributes only one level sets are kept. When both sides hold a struct under the same key, the structs merge field by field:
val base: EvaluationContext =
EvaluationContext("user-1")
.withAttribute("plan", "free")
.withAttribute(
"address",
AttributeValue.struct("country" -> AttributeValue.string("NL"), "city" -> AttributeValue.string("Amsterdam"))
)
val overrides: EvaluationContext =
EvaluationContext.empty
.withAttribute("plan", "premium")
.withAttribute("address", AttributeValue.struct("city" -> AttributeValue.string("Utrecht")))
// The right-hand side wins key by key; structs merge field by field.
// targetingKey = user-1, plan = premium, address = {country = NL, city = Utrecht}
val merged: EvaluationContext = base.merge(overrides)
Finally, a hook's before stage may return extra context. That is merged on top of the result, so it wins
over every level, including the invocation.
Context sources¶
A ContextSource is an effect that is run on every evaluation (and on every track call) to pull in context the
application already holds somewhere else: log annotations, an MDC, a tracing baggage item. Nobody has to pass it
along by hand.
// Identity the application already carries as log annotations, pulled in on every evaluation
val fromLogAnnotations: ContextSource =
ContextSource(
ZIO.logAnnotations.map { annotations =>
annotations.get("userId").fold(EvaluationContext.empty)(EvaluationContext(_))
}
)
val fromRegion: ContextSource =
ContextSource(ZIO.succeed(EvaluationContext.empty.withAttribute("region", "eu-west-1")))
// Combine sources with ++; the right-hand one wins on a key both set
val config: FeatureFlagsConfig =
FeatureFlagsConfig().withContextSource(fromRegion ++ fromLogAnnotations)
// Anything logged-annotated with a userId is now targeted at that user, with no context passed by hand
val handleRequest: ZIO[FeatureFlags, FeatureFlagError, Boolean] =
ZIO.logAnnotate("userId", "user-42") {
FeatureFlags.boolean("new-ui", false)
}
- It sits between the client and the scoped levels: ambient identity beats static client and global context, while an
explicit
withContextor a call's own context still wins over it. currentreturns aUIO, so a source cannot fail an evaluation. ReturnEvaluationContext.emptywhen there is nothing to add.- It runs on every evaluation, so keep it cheap: read a
FiberRefor a thread local, never call a remote service. a ++ bcombines two sources;bwins on a key both set.ContextSource.emptyadds nothing.
A before hook cannot do this job: its addition always lands on top of everything, so it would override the context
the caller passed explicitly.
Example: per-request context¶
final case class Request(userId: String, country: String, plan: String)
def handle(request: Request): ZIO[FeatureFlags, FeatureFlagError, String] =
val ctx = EvaluationContext(request.userId)
.withAttribute("country", request.country)
.withAttribute("plan", request.plan)
FeatureFlags.withContext(ctx) {
for
newUi <- FeatureFlags.boolean("new-ui", false)
banner <- FeatureFlags.string("banner", "none")
yield s"newUi=$newUi banner=$banner"
}
Everything inside the block, including fibers it forks, is evaluated for that user.
Reference¶
| Method | Description |
|---|---|
EvaluationContext(key) |
a context with a targeting key |
EvaluationContext.empty |
no key, no attributes |
EvaluationContext.builder |
.targetingKey(...), .attribute(...), .attributes(...), .build |
EvaluationContext.forEntity(id, type = "user") |
key id, plus entityId and entityType attributes |
EvaluationContext.withAttributes(pairs*) |
attributes, no key |
ctx.merge(other) |
other wins on conflicts; structs merge recursively |
ctx.isEmpty / ctx.nonEmpty |
no key and no attributes |
FeatureFlags.setGlobalContext / globalContext |
write / read the global level |
FeatureFlags.setClientContext / clientContext |
write / read the client level |
FeatureFlags.withContext(ctx)(zio) |
the scoped level, for zio |
ContextSource(effect), ++, ContextSource.empty |
the pulled level |