diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 36d8519..ecc9113 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -36,8 +36,8 @@ jobs:
id: version
shell: bash
run: |
- if [[ ! "$GITHUB_REF_NAME" =~ ^v3\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
- echo "Expected a Featurevisor Java v3 semantic version tag" >&2
+ if [[ ! "$GITHUB_REF_NAME" =~ ^v4\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
+ echo "Expected a Featurevisor Java v4 semantic version tag" >&2
exit 1
fi
version="${GITHUB_REF_NAME#v}"
diff --git a/README.md b/README.md
index f2c4d13..093b3e9 100644
--- a/README.md
+++ b/README.md
@@ -22,10 +22,9 @@ This SDK supports Featurevisor v3 behavior and v2 datafiles. Generated datafiles
- [Getting variation](#getting-variation)
- [Getting variables](#getting-variables)
- [Type specific methods](#type-specific-methods)
-- [Getting all evaluations](#getting-all-evaluations)
-- [Sticky](#sticky)
- - [Initialize with sticky](#initialize-with-sticky)
- - [Set sticky afterwards](#set-sticky-afterwards)
+- [Getting global variables](#getting-global-variables)
+- [Getting aggregate evaluations](#getting-aggregate-evaluations)
+- [Sticky features and variables](#sticky-features-and-variables)
- [Setting datafile](#setting-datafile)
- [Merging by default](#merging-by-default)
- [Replacing](#replacing)
@@ -38,7 +37,8 @@ This SDK supports Featurevisor v3 behavior and v2 datafiles. Generated datafiles
- [Events](#events)
- [`datafile_set`](#datafile_set)
- [`context_set`](#context_set)
- - [`sticky_set`](#sticky_set)
+ - [`sticky_features_set`](#sticky_features_set)
+ - [`sticky_variables_set`](#sticky_variables_set)
- [`error`](#error)
- [Evaluation details](#evaluation-details)
- [Modules](#modules)
@@ -86,7 +86,7 @@ Add the Featurevisor Java SDK as a dependency with your desired version:
com.featurevisor
featurevisor-java
- 3.0.0
+ 4.0.0
```
@@ -136,7 +136,7 @@ Featurevisor f = Featurevisor.createFeaturevisor(
Most applications only need `Featurevisor.createFeaturevisor`, the `Featurevisor` instance type, and `Featurevisor.FeaturevisorOptions`. Public extension and observability types include `FeaturevisorModule`, `FeaturevisorDiagnostic`, and the datafile model types.
-Concurrent evaluations are safe after an instance is configured. Do not call state-changing methods such as `setDatafile`, `setContext`, `setSticky`, `addModule`, `removeModule`, or `close` concurrently with evaluations or with each other. Apply those changes from a serialized update path. Module, event, and diagnostic callbacks must synchronize mutable state that they capture.
+Concurrent evaluations are safe after an instance is configured. Do not call state changing methods such as `setDatafile`, `setContext`, `setStickyFeatures`, `setStickyVariables`, `addModule`, `removeModule`, or `close` concurrently with evaluations or with each other. Apply those changes from a serialized update path. Module, event, and diagnostic callbacks must synchronize mutable state that they capture.
## Initialization
@@ -167,11 +167,12 @@ We will learn about several different options in the next sections.
## Evaluation types
-We can evaluate 3 types of values against a particular [feature](https://featurevisor.com/docs/features/):
+We can evaluate flags, variations, variables inside features, and [global variables](https://featurevisor.com/docs/global-variables/):
- [**Flag**](#check-if-enabled) (`boolean`): whether the feature is enabled or not
- [**Variation**](#getting-variation) (`Object`): the variation of the feature (if any)
- [**Variables**](#getting-variables): variable values of the feature (if any)
+- [**Global variables**](#getting-global-variables): reusable values that are not owned by a feature
These evaluations are run against the provided context.
@@ -384,7 +385,21 @@ If a variable schema type is `json` and the resolved value is a malformed string
- `getVariableJSONNode(...)`
- `getVariableJSON(...)`
-## Getting all evaluations
+## Getting global variables
+
+Global variables use the same overloaded methods as variables inside features. A call with one key evaluates a global variable, while a call with a feature key and variable key evaluates a variable owned by that feature:
+
+```java
+String message = f.getVariableString("welcomeMessage", context, null);
+Object value = f.getVariable("checkoutSettings", context);
+Evaluation evaluation = f.evaluateVariable("checkoutSettings", context);
+```
+
+The type specific methods are `getVariableBoolean`, `getVariableString`, `getVariableInteger`, `getVariableDouble`, `getVariableArray`, `getVariableObject`, and `getVariableJSON`.
+
+Global variables resolve sticky values first, then required features, then the first matching override, and finally their default value. If required features are unmet, `disabledValue` is used unless `useDefaultWhenDisabled` is enabled. Caller defaults are only used when the evaluation itself has no value.
+
+## Getting aggregate evaluations
You can get evaluations of all features available in the SDK instance:
@@ -392,7 +407,7 @@ You can get evaluations of all features available in the SDK instance:
import com.featurevisor.sdk.EvaluatedFeatures;
import com.featurevisor.sdk.EvaluatedFeature;
-EvaluatedFeatures allEvaluations = f.getAllEvaluations(context);
+EvaluatedFeatures allEvaluations = f.getFeatureEvaluations(context);
// Access the evaluations map
Map evaluations = allEvaluations.getValue();
@@ -416,13 +431,17 @@ System.out.println(evaluations);
This is handy especially when you want to pass all evaluations from a backend application to the frontend.
-## Sticky
+Global variables can be evaluated together as well:
-For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/):
+```java
+Map variables = f.getVariableEvaluations(context, null, null);
+```
-Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides; use `new Featurevisor.SpawnOptions().sticky(...)` when a child needs its own sticky state.
+## Sticky features and variables
-### Initialize with sticky
+For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/):
+
+Sticky values belong to an SDK or child instance. Feature sticky values and global variable sticky values are independent.
```java
Map stickyFeatures = new HashMap<>();
@@ -443,20 +462,20 @@ stickyFeatures.put("anotherFeatureKey", anotherFeatureSticky);
Featurevisor f = Featurevisor.createFeaturevisor(new Featurevisor.FeaturevisorOptions()
.datafile(datafile)
- .sticky(stickyFeatures));
+ .stickyFeatures(stickyFeatures)
+ .stickyVariables(Map.of("welcomeMessage", "Hello")));
```
Once initialized with sticky features, the SDK will look for values there first before evaluating the targeting conditions and going through the bucketing process.
-### Set sticky afterwards
-
You can also set sticky features after the SDK is initialized:
```java
Map stickyFeatures = new HashMap<>();
// ... build sticky features map
-f.setSticky(stickyFeatures, true); // replace existing sticky features
+f.setStickyFeatures(stickyFeatures, true); // replace existing sticky features
+f.setStickyVariables(Map.of("welcomeMessage", "Welcome back"), true);
```
## Setting datafile
@@ -469,7 +488,7 @@ f.setDatafile(datafileContent);
### Merging by default
-By default, `setDatafile(datafile)` merges the incoming datafile with the SDK's stored datafile. Incoming top-level metadata is used, and incoming segments/features override existing segments/features with the same keys.
+By default, `setDatafile(datafile)` merges the incoming datafile with the SDK's stored datafile. Incoming top level metadata is used, and incoming segments, features, and global variables override existing entities with the same keys.
This means you can call `setDatafile` more than once with different datafiles, and the SDK instance accumulates their features and segments together. This is what makes [loading datafiles on demand](#loading-datafiles-on-demand) possible.
@@ -587,6 +606,9 @@ FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.DATAFILE_SET, (
@SuppressWarnings("unchecked")
List features = (List) event.get("features");
+ @SuppressWarnings("unchecked")
+ List variables = (List) event.get("variables");
+
// handle here
});
@@ -594,7 +616,7 @@ FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.DATAFILE_SET, (
unsubscribe.unsubscribe();
```
-The `features` array will contain keys of features that have either been:
+The `features` and `variables` arrays contain directly changed entities and entities affected through segment or required feature dependencies.
- added, or
- updated, or
@@ -614,10 +636,10 @@ FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.CONTEXT_SET, (e
});
```
-### `sticky_set`
+### `sticky_features_set`
```java
-FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.STICKY_SET, (event) -> {
+FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.STICKY_FEATURES_SET, (event) -> {
Boolean replaced = (Boolean) event.get("replaced"); // true if sticky features got replaced
@SuppressWarnings("unchecked")
List features = (List) event.get("features"); // list of all affected feature keys
@@ -626,6 +648,15 @@ FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.STICKY_SET, (ev
});
```
+### `sticky_variables_set`
+
+```java
+FeaturevisorUnsubscribe unsubscribe = f.on(FeaturevisorEventName.STICKY_VARIABLES_SET, (event) -> {
+ @SuppressWarnings("unchecked")
+ List variables = (List) event.get("variables");
+});
+```
+
### `error`
```java
@@ -650,6 +681,9 @@ Evaluation evaluation = f.evaluateVariation(featureKey, context);
// variable
Evaluation evaluation = f.evaluateVariable(featureKey, variableKey, context);
+
+// global variable
+Evaluation evaluation = f.evaluateVariable(variableKey, context);
```
The returned `Evaluation` exposes the following properties:
@@ -673,6 +707,8 @@ And optionally these properties depending on whether you are evaluating a featur
Modules allow you to intercept the evaluation process and customize it further as per your needs.
+For feature evaluations, all `before` callbacks run in registration order, followed by all `beforeEvaluation` callbacks. After evaluation and caller defaults, all `afterEvaluation` callbacks run, followed by all `after` callbacks. Global variable evaluations use only `beforeEvaluation` and `afterEvaluation`. Required feature checks run through the complete module pipeline, and transformed defaults are preserved.
+
### Defining a module
A module is a `FeaturevisorModule` with a unique `name` and optional lifecycle functions:
@@ -692,6 +728,9 @@ FeaturevisorModule myCustomModule = new FeaturevisorModule("my-custom-module")
return options.copy().context(context);
})
+ // before any feature or global variable evaluation
+ .beforeEvaluation(options -> options)
+
// configure bucket key
.bucketKey(options -> {
String bucketKey = options.getBucketKey();
@@ -707,6 +746,9 @@ FeaturevisorModule myCustomModule = new FeaturevisorModule("my-custom-module")
// after evaluation
.after((evaluation, options) -> evaluation)
+ // after any feature or global variable evaluation
+ .afterEvaluation((evaluation, options) -> evaluation)
+
// called by f.close()
.close(() -> {
// clean up resources
@@ -780,7 +822,8 @@ String variableValue = childF.getVariableString("my_feature", "my_variable");
Similar to parent SDK, child instances also support several additional methods:
- `setContext`
-- `setSticky`
+- `setStickyFeatures`
+- `setStickyVariables`
- `evaluateFlag`
- `isEnabled`
- `evaluateVariation`
@@ -795,7 +838,8 @@ Similar to parent SDK, child instances also support several additional methods:
- `getVariableObject`
- `getVariableJSON`
- `getVariableJSONNode`
-- `getAllEvaluations`
+- `getFeatureEvaluations`
+- `getVariableEvaluations`
- `on`
- `close`
@@ -855,7 +899,7 @@ Add the provider with the same version as the Featurevisor Java SDK:
com.featurevisor
featurevisor-openfeature
- FEATUREVISOR_VERSION
+ 4.0.0
```
@@ -880,9 +924,9 @@ var client = api.getClient();
boolean enabled = client.getBooleanValue("checkout", false, new ImmutableContext("user-123"));
```
-Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Lists, structures, and JSON variables use the object resolver.
+Use `checkout` for a flag, `checkout:variation` for its variation, `checkout:title` for its `title` variable, and `variable:welcomeMessage` for a global variable. Boolean variables use the boolean resolver. Lists, structures, and JSON variables use the object resolver.
-OpenFeature's targeting key maps to `userId` by default. `targetingKeyField`, `keySeparator`, and `variationKey` on `FeaturevisorOpenFeatureProvider.Options` can customize the mapping.
+OpenFeature's targeting key maps to `userId` by default. `targetingKeyField`, `keySeparator`, `variationKey`, and `globalVariablePrefix` on `FeaturevisorOpenFeatureProvider.Options` can customize the mapping. The global variable prefix defaults to `variable` and cannot contain the configured separator.
You can also reuse an existing Featurevisor instance:
@@ -927,7 +971,7 @@ $ make verify-artifacts
### Releasing
1. Merge the release changes into `main`.
-2. Tag the release with a `v` prefix, such as `v3.0.0`, and push the tag.
+2. Tag the release with a `v` prefix, such as `v4.0.0`, and push the tag.
3. GitHub Actions verifies and publishes the parent POM, Java SDK, and OpenFeature provider to [GitHub Packages](https://github.com/orgs/featurevisor/packages?repo_name=featurevisor-java).
4. Create the corresponding [GitHub release](https://github.com/featurevisor/featurevisor-java/releases).
diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json
index 49396ce..d5870a5 100644
--- a/conformance/sdk-v3.json
+++ b/conformance/sdk-v3.json
@@ -1,5 +1,5 @@
{
- "version": 2,
+ "version": 6,
"description": "Featurevisor v3 cross SDK compatibility contracts",
"bucketing": {
"minimum": 0,
@@ -81,6 +81,594 @@
"schemaVersionIsInformational": true,
"schemaVersionType": "string"
},
+ "globalVariables": {
+ "datafile": {
+ "schemaVersion": "2",
+ "revision": "global-variables",
+ "segments": {
+ "netherlands": {
+ "conditions": {
+ "attribute": "country",
+ "operator": "equals",
+ "value": "nl"
+ }
+ }
+ },
+ "features": {
+ "enabledFeature": {
+ "bucketBy": "userId",
+ "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }]
+ },
+ "disabledFeature": {
+ "bucketBy": "userId",
+ "traffic": []
+ },
+ "variationFeature": {
+ "bucketBy": "userId",
+ "variations": [{ "value": "control" }, { "value": "treatment" }],
+ "force": [{ "segments": "*", "enabled": true, "variation": "treatment" }],
+ "traffic": []
+ },
+ "shared": {
+ "bucketBy": "userId",
+ "variablesSchema": {
+ "owned": { "type": "string", "defaultValue": "feature-value" }
+ },
+ "force": [{ "segments": "*", "enabled": true }],
+ "traffic": []
+ }
+ },
+ "variables": {
+ "shared": { "type": "string", "defaultValue": "global-value" },
+ "stringValue": { "type": "string", "defaultValue": "hello" },
+ "integerValue": { "type": "integer", "defaultValue": 1 },
+ "doubleValue": { "type": "double", "defaultValue": 1.5 },
+ "booleanValue": { "type": "boolean", "defaultValue": true },
+ "arrayValue": { "type": "array", "defaultValue": ["one", "two"] },
+ "objectValue": { "type": "object", "defaultValue": { "enabled": true } },
+ "jsonValue": { "type": "json", "defaultValue": "{\"enabled\":true}" },
+ "requiredDisabled": {
+ "type": "string",
+ "defaultValue": "default",
+ "disabledValue": "disabled",
+ "requiredFeatures": ["disabledFeature"]
+ },
+ "requiredMissingValue": {
+ "type": "string",
+ "defaultValue": "default",
+ "requiredFeatures": ["disabledFeature"]
+ },
+ "requiredUsesDefault": {
+ "type": "string",
+ "defaultValue": "default",
+ "disabledValue": "disabled",
+ "useDefaultWhenDisabled": true,
+ "requiredFeatures": ["disabledFeature"]
+ },
+ "requiredVariation": {
+ "type": "string",
+ "defaultValue": "matched",
+ "disabledValue": "disabled",
+ "requiredFeatures": [{ "feature": "variationFeature", "variation": "treatment" }]
+ },
+ "overrideRequirement": {
+ "type": "string",
+ "defaultValue": "default",
+ "overrides": [
+ {
+ "key": "blocked",
+ "segments": "*",
+ "requiredFeatures": ["disabledFeature"],
+ "value": "blocked"
+ }
+ ]
+ },
+ "orderedOverrides": {
+ "type": "string",
+ "defaultValue": "default",
+ "overrides": [
+ {
+ "key": "blocked",
+ "segments": "*",
+ "requiredFeatures": ["disabledFeature"],
+ "value": "blocked"
+ },
+ {
+ "key": "nl-pro",
+ "keyPath": ["europe", "netherlands", "pro"],
+ "segments": "netherlands",
+ "conditions": {
+ "attribute": "plan",
+ "operator": "equals",
+ "value": "pro"
+ },
+ "requiredFeatures": ["enabledFeature"],
+ "value": "matched"
+ },
+ { "key": "catch-all", "segments": "*", "value": "fallback" }
+ ]
+ }
+ }
+ },
+ "cases": [
+ {
+ "name": "string default",
+ "key": "stringValue",
+ "expectedValue": "hello",
+ "expectedReason": "variable_default"
+ },
+ {
+ "name": "integer default",
+ "key": "integerValue",
+ "expectedValue": 1,
+ "expectedReason": "variable_default"
+ },
+ {
+ "name": "double default",
+ "key": "doubleValue",
+ "expectedValue": 1.5,
+ "expectedReason": "variable_default"
+ },
+ {
+ "name": "boolean default",
+ "key": "booleanValue",
+ "expectedValue": true,
+ "expectedReason": "variable_default"
+ },
+ {
+ "name": "array default",
+ "key": "arrayValue",
+ "expectedValue": ["one", "two"],
+ "expectedReason": "variable_default"
+ },
+ {
+ "name": "object default",
+ "key": "objectValue",
+ "expectedValue": { "enabled": true },
+ "expectedReason": "variable_default"
+ },
+ {
+ "name": "json default",
+ "key": "jsonValue",
+ "expectedValue": "{\"enabled\":true}",
+ "expectedReason": "variable_default"
+ },
+ {
+ "name": "required unmet with disabled value",
+ "key": "requiredDisabled",
+ "expectedValue": "disabled",
+ "expectedReason": "required_features_unmet"
+ },
+ {
+ "name": "required unmet without value",
+ "key": "requiredMissingValue",
+ "expectedReason": "required_features_unmet"
+ },
+ {
+ "name": "required unmet with caller default",
+ "key": "requiredMissingValue",
+ "defaultVariableValue": "caller",
+ "expectedValue": "caller",
+ "expectedReason": "required_features_unmet"
+ },
+ {
+ "name": "required unmet using variable default",
+ "key": "requiredUsesDefault",
+ "expectedValue": "default",
+ "expectedReason": "required_features_unmet"
+ },
+ {
+ "name": "required variation matched",
+ "key": "requiredVariation",
+ "expectedValue": "matched",
+ "expectedReason": "variable_default"
+ },
+ {
+ "name": "unmet override requirement falls through",
+ "key": "overrideRequirement",
+ "expectedValue": "default",
+ "expectedReason": "variable_default"
+ },
+ {
+ "name": "segment and condition override",
+ "key": "orderedOverrides",
+ "context": { "userId": "1", "country": "nl", "plan": "pro" },
+ "expectedValue": "matched",
+ "expectedReason": "variable_override_rule",
+ "expectedOverrideIndex": 1,
+ "expectedOverrideKey": "nl-pro",
+ "expectedOverridePath": ["europe", "netherlands", "pro"]
+ },
+ {
+ "name": "catch all override",
+ "key": "orderedOverrides",
+ "context": { "userId": "1", "country": "de", "plan": "pro" },
+ "expectedValue": "fallback",
+ "expectedReason": "variable_override_rule",
+ "expectedOverrideIndex": 2,
+ "expectedOverrideKey": "catch-all"
+ },
+ {
+ "name": "sticky precedence without definition",
+ "key": "absent",
+ "stickyVariables": { "absent": "sticky" },
+ "expectedValue": "sticky",
+ "expectedReason": "sticky"
+ }
+ ],
+ "overloadCase": {
+ "sharedKey": "shared",
+ "featureVariableKey": "owned",
+ "expectedGlobalValue": "global-value",
+ "expectedFeatureValue": "feature-value"
+ },
+ "datafileUpdateCase": {
+ "initial": {
+ "schemaVersion": "2",
+ "revision": "initial",
+ "segments": {},
+ "features": {
+ "retained": { "hash": "feature-retained", "bucketBy": "userId", "traffic": [] },
+ "changed": { "hash": "feature-old", "bucketBy": "userId", "traffic": [] }
+ },
+ "variables": {
+ "retained": { "hash": "variable-retained", "type": "string", "defaultValue": "retained" },
+ "changed": { "hash": "variable-old", "type": "string", "defaultValue": "old" }
+ }
+ },
+ "merge": {
+ "schemaVersion": "2",
+ "revision": "merged",
+ "segments": {},
+ "features": {
+ "changed": { "hash": "feature-new", "bucketBy": "userId", "traffic": [] },
+ "added": { "hash": "feature-added", "bucketBy": "userId", "traffic": [] }
+ },
+ "variables": {
+ "changed": { "hash": "variable-new", "type": "string", "defaultValue": "new" },
+ "added": { "hash": "variable-added", "type": "string", "defaultValue": "added" }
+ }
+ },
+ "expectedAfterMerge": {
+ "features": ["added", "changed", "retained"],
+ "variables": ["added", "changed", "retained"],
+ "changedFeatures": ["changed", "added"],
+ "changedVariables": ["changed", "added"]
+ },
+ "replacement": {
+ "schemaVersion": "2",
+ "revision": "replaced",
+ "segments": {},
+ "features": {
+ "added": { "hash": "feature-added", "bucketBy": "userId", "traffic": [] }
+ },
+ "variables": {
+ "added": { "hash": "variable-added", "type": "string", "defaultValue": "added" }
+ }
+ },
+ "expectedAfterReplacement": {
+ "features": ["added"],
+ "variables": ["added"],
+ "changedFeatures": ["retained", "changed"],
+ "changedVariables": ["retained", "changed"]
+ }
+ },
+ "dependencyUpdateCase": {
+ "modes": [
+ { "name": "merge", "replace": false },
+ { "name": "replacement", "replace": true }
+ ],
+ "initial": {
+ "schemaVersion": "2",
+ "revision": "dependencies-initial",
+ "segments": {
+ "audience": {
+ "conditions": { "attribute": "country", "operator": "equals", "value": "nl" }
+ }
+ },
+ "features": {
+ "segmentFeature": {
+ "hash": "segment-feature",
+ "bucketBy": "userId",
+ "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }]
+ },
+ "segmentDependent": {
+ "hash": "segment-dependent",
+ "bucketBy": "userId",
+ "requiredFeatures": ["segmentFeature"],
+ "traffic": []
+ },
+ "prerequisite": {
+ "hash": "prerequisite-old",
+ "bucketBy": "userId",
+ "traffic": []
+ },
+ "requiredDependent": {
+ "hash": "required-dependent",
+ "bucketBy": "userId",
+ "requiredFeatures": ["prerequisite"],
+ "traffic": []
+ }
+ },
+ "variables": {
+ "bySegment": {
+ "hash": "by-segment",
+ "type": "string",
+ "defaultValue": "default",
+ "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }]
+ },
+ "bySegmentFeature": {
+ "hash": "by-segment-feature",
+ "type": "string",
+ "defaultValue": "default",
+ "requiredFeatures": ["segmentDependent"]
+ },
+ "byRequiredFeature": {
+ "hash": "by-required-feature",
+ "type": "string",
+ "defaultValue": "default",
+ "requiredFeatures": ["requiredDependent"]
+ }
+ }
+ },
+ "updated": {
+ "schemaVersion": "2",
+ "revision": "dependencies-updated",
+ "segments": {
+ "audience": {
+ "conditions": { "attribute": "country", "operator": "equals", "value": "de" }
+ }
+ },
+ "features": {
+ "segmentFeature": {
+ "hash": "segment-feature",
+ "bucketBy": "userId",
+ "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }]
+ },
+ "segmentDependent": {
+ "hash": "segment-dependent",
+ "bucketBy": "userId",
+ "requiredFeatures": ["segmentFeature"],
+ "traffic": []
+ },
+ "prerequisite": {
+ "hash": "prerequisite-new",
+ "bucketBy": "userId",
+ "traffic": []
+ },
+ "requiredDependent": {
+ "hash": "required-dependent",
+ "bucketBy": "userId",
+ "requiredFeatures": ["prerequisite"],
+ "traffic": []
+ }
+ },
+ "variables": {
+ "bySegment": {
+ "hash": "by-segment",
+ "type": "string",
+ "defaultValue": "default",
+ "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }]
+ },
+ "bySegmentFeature": {
+ "hash": "by-segment-feature",
+ "type": "string",
+ "defaultValue": "default",
+ "requiredFeatures": ["segmentDependent"]
+ },
+ "byRequiredFeature": {
+ "hash": "by-required-feature",
+ "type": "string",
+ "defaultValue": "default",
+ "requiredFeatures": ["requiredDependent"]
+ }
+ }
+ },
+ "withoutSegment": {
+ "schemaVersion": "2",
+ "revision": "dependencies-without-segment",
+ "segments": {},
+ "features": {
+ "segmentFeature": {
+ "hash": "segment-feature",
+ "bucketBy": "userId",
+ "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }]
+ },
+ "segmentDependent": {
+ "hash": "segment-dependent",
+ "bucketBy": "userId",
+ "requiredFeatures": ["segmentFeature"],
+ "traffic": []
+ },
+ "prerequisite": {
+ "hash": "prerequisite-old",
+ "bucketBy": "userId",
+ "traffic": []
+ },
+ "requiredDependent": {
+ "hash": "required-dependent",
+ "bucketBy": "userId",
+ "requiredFeatures": ["prerequisite"],
+ "traffic": []
+ }
+ },
+ "variables": {
+ "bySegment": {
+ "hash": "by-segment",
+ "type": "string",
+ "defaultValue": "default",
+ "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }]
+ },
+ "bySegmentFeature": {
+ "hash": "by-segment-feature",
+ "type": "string",
+ "defaultValue": "default",
+ "requiredFeatures": ["segmentDependent"]
+ },
+ "byRequiredFeature": {
+ "hash": "by-required-feature",
+ "type": "string",
+ "defaultValue": "default",
+ "requiredFeatures": ["requiredDependent"]
+ }
+ }
+ },
+ "expectedChangedFeatures": [
+ "prerequisite",
+ "requiredDependent",
+ "segmentDependent",
+ "segmentFeature"
+ ],
+ "expectedChangedVariables": ["byRequiredFeature", "bySegment", "bySegmentFeature"],
+ "expectedRemovedSegmentFeatures": ["segmentDependent", "segmentFeature"],
+ "expectedRemovedSegmentVariables": ["bySegment", "bySegmentFeature"]
+ }
+ },
+ "requiredFeatures": {
+ "datafile": {
+ "schemaVersion": "2",
+ "revision": "required-features",
+ "segments": {},
+ "features": {
+ "enabledFeature": {
+ "bucketBy": "userId",
+ "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }]
+ },
+ "disabledFeature": { "bucketBy": "userId", "traffic": [] },
+ "disabledVariationFeature": {
+ "bucketBy": "userId",
+ "disabledVariationValue": "treatment",
+ "variations": [{ "value": "control" }, { "value": "treatment" }],
+ "traffic": []
+ },
+ "stringRequirement": {
+ "bucketBy": "userId",
+ "requiredFeatures": ["enabledFeature"],
+ "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }]
+ },
+ "explicitEnabledRequirement": {
+ "bucketBy": "userId",
+ "requiredFeatures": [{ "feature": "enabledFeature", "enabled": true }],
+ "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }]
+ },
+ "disabledRequirement": {
+ "bucketBy": "userId",
+ "requiredFeatures": [{ "feature": "disabledFeature", "enabled": false }],
+ "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }]
+ },
+ "missingDisabledRequirement": {
+ "bucketBy": "userId",
+ "requiredFeatures": [{ "feature": "missingFeature", "enabled": false }],
+ "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }]
+ },
+ "enabledAndVariationRequirement": {
+ "bucketBy": "userId",
+ "requiredFeatures": [
+ {
+ "feature": "disabledVariationFeature",
+ "enabled": false,
+ "variation": "treatment"
+ }
+ ],
+ "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }]
+ },
+ "multipleRequirements": {
+ "bucketBy": "userId",
+ "requiredFeatures": [
+ "enabledFeature",
+ { "feature": "disabledFeature", "enabled": false }
+ ],
+ "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }]
+ },
+ "unmetMultipleRequirements": {
+ "bucketBy": "userId",
+ "requiredFeatures": ["enabledFeature", { "feature": "disabledFeature", "enabled": true }],
+ "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }]
+ },
+ "canonicalPrecedence": {
+ "bucketBy": "userId",
+ "required": ["disabledFeature"],
+ "requiredFeatures": ["enabledFeature"],
+ "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }]
+ },
+ "featureVariableOverride": {
+ "bucketBy": "userId",
+ "variablesSchema": {
+ "message": { "type": "string", "defaultValue": "default" }
+ },
+ "traffic": [
+ {
+ "key": "all",
+ "segments": "*",
+ "percentage": 100000,
+ "variableOverrides": {
+ "message": [
+ {
+ "key": "blocked",
+ "requiredFeatures": ["disabledFeature"],
+ "value": "blocked"
+ },
+ {
+ "key": "matched",
+ "requiredFeatures": ["enabledFeature"],
+ "value": "matched"
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+ },
+ "cases": [
+ {
+ "name": "string requirement defaults to enabled",
+ "feature": "stringRequirement",
+ "expectedEnabled": true
+ },
+ {
+ "name": "explicit enabled true",
+ "feature": "explicitEnabledRequirement",
+ "expectedEnabled": true
+ },
+ {
+ "name": "disabled feature satisfies enabled false",
+ "feature": "disabledRequirement",
+ "expectedEnabled": true
+ },
+ {
+ "name": "missing feature satisfies enabled false",
+ "feature": "missingDisabledRequirement",
+ "expectedEnabled": true
+ },
+ {
+ "name": "enabled and variation both match",
+ "feature": "enabledAndVariationRequirement",
+ "expectedEnabled": true
+ },
+ {
+ "name": "multiple requirements use AND",
+ "feature": "multipleRequirements",
+ "expectedEnabled": true
+ },
+ {
+ "name": "one unmet requirement disables feature",
+ "feature": "unmetMultipleRequirements",
+ "expectedEnabled": false
+ },
+ {
+ "name": "requiredFeatures takes precedence over required",
+ "feature": "canonicalPrecedence",
+ "expectedEnabled": true
+ }
+ ],
+ "featureVariableCase": {
+ "feature": "featureVariableOverride",
+ "variable": "message",
+ "expectedValue": "matched",
+ "expectedOverrideKey": "matched"
+ }
+ },
"diagnostics": {
"requiredFields": ["level", "code", "message", "details"],
"detailsType": "object",
@@ -107,11 +695,7 @@
"2024-01-01T00:00:00.250Z",
"2024-01-01T01:00:00.250+01:00"
],
- "semanticVersions": [
- "1.2.3",
- "1.2.3-beta.1",
- "1.2.3+build.5"
- ],
+ "semanticVersions": ["1.2.3", "1.2.3-beta.1", "1.2.3+build.5"],
"invalidSemanticVersion": "invalid",
"invalidSemanticVersionDiagnosticCode": "condition_match_error"
},
@@ -171,6 +755,7 @@
],
"childInstances": {
"contextModel": "snapshot existing parent keys at spawn, inherit newly introduced parent keys, child keys win",
+ "stickyStateModel": "child sticky features and variables replace parent sticky state; omitted child sticky options mean empty sticky state",
"closeRemovesLocalAndDelegatedSubscriptions": true,
"detailedEvaluationMethods": ["flag", "variation", "variable"],
"contextCase": {
@@ -178,11 +763,52 @@
"child": { "country": "de" },
"parentAfterSpawn": { "country": "us", "plan": "pro", "region": "eu" },
"expected": { "country": "de", "plan": "free", "region": "eu" }
+ },
+ "stickyCase": {
+ "datafile": {
+ "schemaVersion": "2",
+ "revision": "child-sticky",
+ "segments": {},
+ "features": {
+ "flag": {
+ "key": "flag",
+ "bucketBy": "userId",
+ "traffic": []
+ }
+ },
+ "variables": {
+ "setting": {
+ "type": "string",
+ "defaultValue": "datafile"
+ }
+ }
+ },
+ "parentStickyFeatures": { "flag": { "enabled": true } },
+ "parentStickyVariables": { "setting": "parent-sticky" },
+ "expectedParent": { "flag": true, "setting": "parent-sticky" },
+ "expectedChildWithoutStickyOptions": { "flag": false, "setting": "datafile" }
+ },
+ "globalJsonCase": {
+ "datafile": {
+ "schemaVersion": "2",
+ "revision": "child-global-json",
+ "segments": {},
+ "features": {},
+ "variables": {
+ "settings": {
+ "type": "json",
+ "defaultValue": "{\"enabled\":true}"
+ }
+ }
+ },
+ "variableKey": "settings",
+ "expected": { "enabled": true }
}
},
"defaults": {
"presenceBased": true,
"values": ["", 0, false, null],
+ "explicitNullBeatsCallerDefault": true,
"aggregateEvaluationPreservesEmptyVariation": true,
"aggregateCase": {
"datafile": {
@@ -205,6 +831,38 @@
}
}
},
+ "modulePipeline": {
+ "featureOrder": [
+ "before:first",
+ "before:second",
+ "beforeEvaluation:first",
+ "beforeEvaluation:second",
+ "afterEvaluation:first",
+ "afterEvaluation:second",
+ "after:first",
+ "after:second"
+ ],
+ "globalOrder": [
+ "beforeEvaluation:first",
+ "beforeEvaluation:second",
+ "afterEvaluation:first",
+ "afterEvaluation:second"
+ ],
+ "requiredFeaturesUseModules": true,
+ "transformedDefaultsAreApplied": true
+ },
+ "lifecycle": {
+ "stickyFeatureEvent": "sticky_features_set",
+ "stickyFeatureDiagnostic": "sticky_features_set",
+ "stickyVariableEvent": "sticky_variables_set",
+ "stickyVariableDiagnostic": "sticky_variables_set",
+ "diagnosticBeforeEvent": true
+ },
+ "openFeature": {
+ "reasonMappings": {
+ "required_features_unmet": "DISABLED"
+ }
+ },
"diagnosticCase": {
"featureKey": "missing",
"expectedLevel": "warn",
diff --git a/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java b/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java
index cf211e0..23d4480 100644
--- a/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java
+++ b/featurevisor-openfeature/src/main/java/com/featurevisor/openfeature/FeaturevisorOpenFeatureProvider.java
@@ -38,6 +38,7 @@ public static final class Options {
private String targetingKeyField = "userId";
private String keySeparator = ":";
private String variationKey = "variation";
+ private String globalVariablePrefix = "variable";
private TrackingHandler onTrack;
public Options featurevisor(Featurevisor value) { this.featurevisor = value; return this; }
@@ -45,6 +46,7 @@ public static final class Options {
public Options targetingKeyField(String value) { this.targetingKeyField = value; return this; }
public Options keySeparator(String value) { this.keySeparator = value; return this; }
public Options variationKey(String value) { this.variationKey = value; return this; }
+ public Options globalVariablePrefix(String value) { this.globalVariablePrefix = value; return this; }
public Options onTrack(TrackingHandler value) { this.onTrack = value; return this; }
}
@@ -52,6 +54,7 @@ public static final class Options {
private final String targetingKeyField;
private final String keySeparator;
private final String variationKey;
+ private final String globalVariablePrefix;
private final TrackingHandler onTrack;
private final FeaturevisorUnsubscribe datafileUnsubscribe;
private final boolean ownsFeaturevisor;
@@ -62,6 +65,10 @@ public FeaturevisorOpenFeatureProvider(Options options) {
this.targetingKeyField = nonEmpty(resolved.targetingKeyField, "userId");
this.keySeparator = nonEmpty(resolved.keySeparator, ":");
this.variationKey = nonEmpty(resolved.variationKey, "variation");
+ this.globalVariablePrefix = nonEmpty(resolved.globalVariablePrefix, "variable");
+ if (this.globalVariablePrefix.contains(this.keySeparator)) {
+ throw new IllegalArgumentException("globalVariablePrefix cannot contain keySeparator");
+ }
this.onTrack = resolved.onTrack;
this.ownsFeaturevisor = resolved.featurevisor == null;
if (resolved.featurevisor != null) {
@@ -142,7 +149,15 @@ private ProviderEvaluation