From a72d6665074d737d51220af048e60d8d3acb28b9 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Fri, 28 Aug 2026 22:55:22 +0200 Subject: [PATCH 1/2] feat: global variables --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 42 ++- conformance/sdk-v3.json | 621 +++++++++++++++++++++++++++++++++++++++- src/child.rs | 240 +++++++++++++++- src/cli/benchmark.rs | 35 ++- src/cli/options.rs | 4 +- src/cli/test.rs | 64 ++++- src/evaluate.rs | 95 ++++-- src/events.rs | 15 + src/instance.rs | 591 ++++++++++++++++++++++++++++++++++++-- src/lib.rs | 10 +- src/modules.rs | 11 + src/types.rs | 51 +++- tests/child.rs | 50 +++- tests/conformance.rs | 205 ++++++++++++- tests/modules.rs | 42 +++ tests/public_api.rs | 3 +- 18 files changed, 1990 insertions(+), 93 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 05a2971..ca138a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,7 +136,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "featurevisor" -version = "0.1.0" +version = "1.0.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 5a37e86..1c87e8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "featurevisor" -version = "0.1.0" +version = "1.0.0" edition = "2021" rust-version = "1.74.0" description = "Featurevisor SDK for Rust: feature flags, experiments, and remote configuration" diff --git a/README.md b/README.md index 88ee1ee..961f522 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,13 @@ Add the SDK to `Cargo.toml`: ```toml [dependencies] -featurevisor = "0.1" +featurevisor = "1" ``` The optional command line runner is available with the `cli` feature: ```toml -featurevisor = { version = "0.1", features = ["cli"] } +featurevisor = { version = "1", features = ["cli"] } ``` ## Public API @@ -101,7 +101,8 @@ Featurevisor evaluates three kinds of values: * a flag, which answers whether a feature is enabled * a variation, which returns a variation value -* a variable, which returns remote configuration for a feature +* a feature variable, which returns remote configuration owned by a feature +* a global variable, which returns remote configuration independently of a feature All evaluations use a context and the feature rules in the active datafile. @@ -190,6 +191,13 @@ let variation = f.get_variation( let value = f.get_variable("my_feature", "backgroundColour", None, None); ``` +Global variables use explicit method names because Rust does not overload functions: + +```rust +let email = f.get_global_variable("supportEmail", None, None); +let evaluation = f.evaluate_global_variable("supportEmail", None, None); +``` + ### Type specific methods The SDK also provides typed getters: @@ -209,10 +217,11 @@ Typed getters do not coerce strings, booleans, or unrelated collections. Integer ## Getting all evaluations ```rust -let evaluations = f.get_all_evaluations(None, &[], None); +let features = f.get_feature_evaluations(None, &[], None); +let variables = f.get_global_variable_evaluations(None, &[], None); ``` -Pass a list of feature keys to limit the result. An empty list evaluates every feature in the datafile. +Pass a list of keys to limit either result. An empty list evaluates every entity of that kind in the datafile. `get_all_evaluations` is a deprecated alias for `get_feature_evaluations`. ## Sticky @@ -232,7 +241,10 @@ sticky.insert("my_feature".to_string(), EvaluatedFeature { }); let f = featurevisor::create_featurevisor(FeaturevisorOptions { - sticky: Some(sticky), + sticky_features: Some(sticky), + sticky_variables: Some(HashMap::from([ + ("supportEmail".to_string(), "sticky@example.com".into()), + ])), ..Default::default() }); ``` @@ -240,15 +252,16 @@ let f = featurevisor::create_featurevisor(FeaturevisorOptions { ### Set sticky afterwards ```rust -f.set_sticky(HashMap::new(), false); -f.set_sticky(HashMap::new(), true); // replace all sticky values +f.set_sticky_features(HashMap::new(), false); +f.set_sticky_variables(HashMap::new(), false); +f.set_sticky_features(HashMap::new(), true); // replace all sticky feature values ``` ## Setting datafile ### Merging by default -`set_datafile` merges incoming features and segments into the stored datafile. Incoming entries replace entries with the same key. +`set_datafile` merges incoming features, global variables, and segments into the stored datafile. Incoming entries replace entries with the same key. ```rust f.set_datafile(featurevisor::DatafileInput::Json(datafile_json), false); @@ -268,7 +281,7 @@ Fetch a datafile using the HTTP client used by your application, then pass its b ### Updating datafile -Call `set_datafile` whenever your application receives a newer datafile. A `datafile_set` event contains the changed feature keys and revision information. +Call `set_datafile` whenever your application receives a newer datafile. A `datafile_set` event contains changed feature and global variable keys, including dependants affected by changed requirements or segments, plus revision information. ### Interval based update @@ -311,7 +324,7 @@ Register an event callback with `f.on(event_name, callback)`. The returned unsub ### `datafile_set` -Emitted after a valid datafile is stored. Details include `revision`, `previousRevision`, `revisionChanged`, `features`, and `replaced`. +Emitted after a valid datafile is stored. Details include `revision`, `previousRevision`, `revisionChanged`, `features`, `variables`, and `replaced`. ### `context_set` @@ -321,6 +334,8 @@ Emitted after context is merged or replaced. Details include `context` and `repl Emitted after sticky features are merged or replaced. Details include `features` and `replaced`. +`sticky_variables_set` is emitted after sticky global variables are merged or replaced. Its details include `variables` and `replaced`. + ### `error` Emitted for error diagnostics. The details include the complete `diagnostic`. @@ -335,6 +350,7 @@ println!("{evaluation:?}"); let variation_evaluation = f.evaluate_variation("my_feature", None, None); let variable_evaluation = f.evaluate_variable("my_feature", "my_variable", None, None); +let global_evaluation = f.evaluate_global_variable("supportEmail", None, None); ``` An evaluation includes the type, feature key, reason, and when applicable the bucket key, bucket value, rule, traffic, variation, variable, or diagnostic error. @@ -382,7 +398,7 @@ f.remove_module("audit"); drop(unsubscribe); ``` -Modules run `before` callbacks in registration order, then bucket key and bucket value callbacks during bucketing, and finally `after` callbacks in registration order. Duplicate names are reported and ignored. +Modules run `before_evaluation` callbacks in registration order for feature and global variable evaluations, then bucket key and bucket value callbacks during feature bucketing, and finally `after_evaluation` callbacks in registration order. The older `before` and `after` callbacks remain feature only compatibility callbacks. Duplicate names are reported and ignored. ## Child instance @@ -431,7 +447,7 @@ featurevisor benchmark --projectDirectoryPath=../featurevisor/examples/example-1 ``` Use either `--variation` or `--variable=` when benchmarking. They cannot be -used together. The command reports minimum, average, maximum, and total +used together. Pass `--variable=` without `--feature` to benchmark a global variable. The command reports minimum, average, maximum, and total durations in fractional milliseconds for the individual SDK evaluations. ### Assess distribution diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json index 49396ce..682a73d 100644 --- a/conformance/sdk-v3.json +++ b/conformance/sdk-v3.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 5, "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,6 +763,30 @@ "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" } } }, "defaults": { diff --git a/src/child.rs b/src/child.rs index 409f0d9..c892cb0 100644 --- a/src/child.rs +++ b/src/child.rs @@ -2,13 +2,16 @@ use crate::emitter::Emitter; use crate::evaluate::{Evaluation, EvaluationType}; use crate::events::{ContextSetDetails, EventDetails, EventHandler, EventName, StickySetDetails}; use crate::instance::{Featurevisor, OverrideOptions}; -use crate::types::{Context, EvaluatedFeatures, StickyFeatures, VariableValue}; +use crate::types::{ + Context, EvaluatedFeatures, EvaluatedVariables, StickyFeatures, StickyVariables, VariableValue, +}; use crate::Unsubscribe; use std::sync::{Arc, Mutex}; struct ChildInner { context: Context, - sticky: StickyFeatures, + sticky_features: StickyFeatures, + sticky_variables: StickyVariables, emitter: Emitter, parent_unsubscribers: Vec, closed: bool, @@ -22,12 +25,18 @@ pub struct FeaturevisorChild { } impl FeaturevisorChild { - pub(crate) fn new(parent: Featurevisor, context: Context, sticky: StickyFeatures) -> Self { + pub(crate) fn new( + parent: Featurevisor, + context: Context, + sticky_features: StickyFeatures, + sticky_variables: StickyVariables, + ) -> Self { Self { parent, inner: Arc::new(Mutex::new(ChildInner { context, - sticky, + sticky_features, + sticky_variables, emitter: Emitter::default(), parent_unsubscribers: Vec::new(), closed: false, @@ -35,10 +44,16 @@ impl FeaturevisorChild { } } - fn options(&self) -> (Context, StickyFeatures) { + fn options(&self) -> (Context, StickyFeatures, StickyVariables) { self.inner .lock() - .map(|inner| (inner.context.clone(), inner.sticky.clone())) + .map(|inner| { + ( + inner.context.clone(), + inner.sticky_features.clone(), + inner.sticky_variables.clone(), + ) + }) .unwrap_or_default() } @@ -69,7 +84,7 @@ impl FeaturevisorChild { } /// Returns the child context merged with an optional per evaluation context. pub fn get_context(&self, context: Option<&Context>) -> Context { - let (stored, _) = self.options(); + let (stored, _, _) = self.options(); let mut merged = stored; if let Some(context) = context { merged.extend(context.clone()); @@ -78,6 +93,11 @@ impl FeaturevisorChild { } /// Updates sticky evaluations used by this child. pub fn set_sticky(&self, sticky: StickyFeatures, replace: bool) { + self.set_sticky_features(sticky, replace); + } + + /// Updates sticky feature evaluations used by this child. + pub fn set_sticky_features(&self, sticky: StickyFeatures, replace: bool) { let (features, emitter) = { let mut inner = match self.inner.lock() { Ok(inner) => inner, @@ -87,12 +107,12 @@ impl FeaturevisorChild { return; } if replace { - inner.sticky = sticky; + inner.sticky_features = sticky; } else { - inner.sticky.extend(sticky); + inner.sticky_features.extend(sticky); } ( - inner.sticky.keys().cloned().collect(), + inner.sticky_features.keys().cloned().collect(), inner.emitter.clone(), ) }; @@ -105,9 +125,41 @@ impl FeaturevisorChild { ); } + /// Updates sticky global variable values used by this child. + pub fn set_sticky_variables(&self, sticky: StickyVariables, replace: bool) { + let (variables, emitter) = { + let mut inner = match self.inner.lock() { + Ok(inner) => inner, + Err(_) => return, + }; + if inner.closed { + return; + } + if replace { + inner.sticky_variables = sticky; + } else { + inner.sticky_variables.extend(sticky); + } + ( + inner.sticky_variables.keys().cloned().collect(), + inner.emitter.clone(), + ) + }; + emitter.emit( + EventName::StickyVariablesSet, + EventDetails::StickyVariablesSet(crate::events::StickyVariablesSetDetails { + variables, + replaced: replace, + }), + ); + } + /// Subscribes to child events and returns an idempotent cleanup function. pub fn on(&self, event: EventName, callback: EventHandler) -> Unsubscribe { - if matches!(event, EventName::ContextSet | EventName::StickySet) { + if matches!( + event, + EventName::ContextSet | EventName::StickySet | EventName::StickyVariablesSet + ) { return self .inner .lock() @@ -169,7 +221,7 @@ impl FeaturevisorChild { context: Option<&Context>, options: Option<&OverrideOptions>, ) -> Evaluation { - let (stored, sticky) = self.options(); + let (stored, sticky, _) = self.options(); let mut merged = stored; if let Some(context) = context { merged.extend(context.clone()); @@ -343,13 +395,13 @@ impl FeaturevisorChild { self.get_variable(feature_key, variable_key, context, options) } /// Evaluates all requested features, or every feature when no keys are supplied. - pub fn get_all_evaluations( + pub fn get_feature_evaluations( &self, context: Option<&Context>, feature_keys: &[String], options: Option<&OverrideOptions>, ) -> EvaluatedFeatures { - let (stored, sticky) = self.options(); + let (stored, sticky, _) = self.options(); let mut merged = stored; if let Some(context) = context { merged.extend(context.clone()); @@ -357,4 +409,164 @@ impl FeaturevisorChild { self.parent .get_all_evaluations_with_sticky(Some(&merged), feature_keys, options, sticky) } + /// Deprecated alias for [`FeaturevisorChild::get_feature_evaluations`]. + #[deprecated(note = "use get_feature_evaluations")] + pub fn get_all_evaluations( + &self, + context: Option<&Context>, + feature_keys: &[String], + options: Option<&OverrideOptions>, + ) -> EvaluatedFeatures { + self.get_feature_evaluations(context, feature_keys, options) + } + + /// Evaluates a global variable and returns evaluation details. + pub fn evaluate_global_variable( + &self, + variable_key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Evaluation { + let (stored, _, sticky_variables) = self.options(); + let mut merged = stored; + if let Some(context) = context { + merged.extend(context.clone()); + } + self.parent.evaluate_global_variable_with_sticky( + variable_key, + Some(&merged), + options, + sticky_variables, + ) + } + + /// Returns a global variable value, if one is available. + pub fn get_global_variable( + &self, + variable_key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option { + let evaluation = self.evaluate_global_variable(variable_key, context, options); + let is_json = evaluation + .variable + .as_ref() + .map(|variable| variable.variable_type == "json") + .unwrap_or(false); + let value = evaluation.variable_value?; + if is_json { + if let VariableValue::String(string) = &value { + return serde_json::from_str::(string) + .ok() + .map(VariableValue::from_json); + } + } + Some(value) + } + + /// Returns a global variable as a boolean when its value has that type. + pub fn get_global_variable_boolean( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option { + match self.get_global_variable(key, context, options)? { + VariableValue::Boolean(value) => Some(value), + _ => None, + } + } + /// Returns a global variable as a string when its value has that type. + pub fn get_global_variable_string( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option { + match self.get_global_variable(key, context, options)? { + VariableValue::String(value) => Some(value), + _ => None, + } + } + /// Returns a global variable as an integer when its value has that type. + pub fn get_global_variable_integer( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option { + match self.get_global_variable(key, context, options)? { + VariableValue::Integer(value) => Some(value), + VariableValue::Double(value) if value.is_finite() && value.fract() == 0.0 => { + Some(value as i64) + } + _ => None, + } + } + /// Returns a global variable as a double when its value has that type. + pub fn get_global_variable_double( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option { + match self.get_global_variable(key, context, options)? { + VariableValue::Integer(value) => Some(value as f64), + VariableValue::Double(value) if value.is_finite() => Some(value), + _ => None, + } + } + /// Returns a global variable as an array when its value has that type. + pub fn get_global_variable_array( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option> { + match self.get_global_variable(key, context, options)? { + VariableValue::Array(value) => Some(value), + _ => None, + } + } + /// Returns a global variable as an object when its value has that type. + pub fn get_global_variable_object( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option> { + match self.get_global_variable(key, context, options)? { + VariableValue::Object(value) => Some(value), + _ => None, + } + } + /// Returns a global variable value without imposing a more specific Rust type. + pub fn get_global_variable_json( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option { + self.get_global_variable(key, context, options) + } + + /// Evaluates requested global variables, or every global variable when no keys are supplied. + pub fn get_global_variable_evaluations( + &self, + context: Option<&Context>, + variable_keys: &[String], + options: Option<&OverrideOptions>, + ) -> EvaluatedVariables { + let keys = if variable_keys.is_empty() { + self.parent.get_global_variable_keys() + } else { + variable_keys.to_vec() + }; + keys.into_iter() + .filter_map(|key| { + self.get_global_variable(&key, context, options) + .map(|value| (key, value)) + }) + .collect() + } } diff --git a/src/cli/benchmark.rs b/src/cli/benchmark.rs index 88f311f..28bf608 100644 --- a/src/cli/benchmark.rs +++ b/src/cli/benchmark.rs @@ -32,11 +32,22 @@ fn one(options: &BenchmarkOptions, project: &Path, target: Option<&str>) -> Resu for _ in 0..n { let start = Instant::now(); if let Some(variable) = &options.variable { - let _ = f.get_variable(&options.feature, variable, Some(&context), None); + if let Some(feature) = &options.feature { + let _ = f.get_variable(feature, variable, Some(&context), None); + } else { + let _ = f.get_global_variable(variable, Some(&context), None); + } } else if options.variation { - let _ = f.get_variation(&options.feature, Some(&context), None); + let _ = f.get_variation( + options.feature.as_deref().unwrap_or_default(), + Some(&context), + None, + ); } else { - let _ = f.is_enabled(&options.feature, Some(&context)); + let _ = f.is_enabled( + options.feature.as_deref().unwrap_or_default(), + Some(&context), + ); } durations.push(start.elapsed().as_nanos()); } @@ -52,8 +63,22 @@ fn one(options: &BenchmarkOptions, project: &Path, target: Option<&str>) -> Resu "flag" }; println!(); - println!("Benchmark Featurevisor feature"); - println!(" Feature: {}", options.feature); + println!( + "Benchmark Featurevisor {}", + if options.feature.is_some() { + "feature" + } else { + "variable" + } + ); + if let Some(feature) = &options.feature { + println!(" Feature: {feature}"); + } + if options.feature.is_none() { + if let Some(variable) = &options.variable { + println!(" Variable: {variable}"); + } + } println!( " Environment: {}", options.common.environment.as_deref().unwrap_or("false") diff --git a/src/cli/options.rs b/src/cli/options.rs index bdc6de6..3e0e9da 100644 --- a/src/cli/options.rs +++ b/src/cli/options.rs @@ -54,8 +54,8 @@ pub struct TestOptions { pub struct BenchmarkOptions { #[command(flatten)] pub common: CommonOptions, - #[arg(long)] - pub feature: String, + #[arg(long, required_unless_present = "variable")] + pub feature: Option, #[arg(long, action = clap::ArgAction::SetTrue, conflicts_with = "variable")] pub variation: bool, #[arg(long, conflicts_with = "variation")] diff --git a/src/cli/test.rs b/src/cli/test.rs index ff8a000..e34eaf0 100644 --- a/src/cli/test.rs +++ b/src/cli/test.rs @@ -306,9 +306,12 @@ fn compare_children( let child_sdk = sdk.spawn( child_context, SpawnOptions { - sticky: assertion + sticky_features: child .get("sticky") .and_then(|value| serde_json::from_value(value.clone()).ok()), + sticky_variables: child + .get("stickyVariables") + .and_then(|value| serde_json::from_value(value.clone()).ok()), }, ); if let Some(expected) = child @@ -346,6 +349,7 @@ fn run_assertion( ) -> Result, String> { let feature_key = test.get("feature").and_then(JsonValue::as_str); let segment_key = test.get("segment").and_then(JsonValue::as_str); + let variable_key = test.get("variable").and_then(JsonValue::as_str); let environment = assertion.get("environment").and_then(JsonValue::as_str); let target = assertion.get("target").and_then(JsonValue::as_str); @@ -388,8 +392,59 @@ fn run_assertion( }; } + if let Some(variable_key) = variable_key { + let selected_key = datafile_key(environment, target); + let datafile = datafiles + .get(&selected_key) + .or_else(|| base_datafile(datafiles, environment)); + let Some(datafile) = datafile else { + return Err(format!( + "No datafile available for variable assertion {variable_key}" + )); + }; + let f = crate::create_featurevisor(FeaturevisorOptions { + datafile: Some(input(datafile.clone())), + context: Some(context_from_json(assertion.get("context"))), + sticky_variables: assertion + .get("stickyVariables") + .and_then(|value| serde_json::from_value(value.clone()).ok()), + log_level: Some(log_level(options)), + ..Default::default() + }); + let evaluation_options = OverrideOptions { + default_variable_value: assertion + .get("defaultVariableValue") + .cloned() + .map(crate::VariableValue::from_json), + ..Default::default() + }; + let evaluation = f.evaluate_global_variable(variable_key, None, Some(&evaluation_options)); + let mut errors = Vec::new(); + if let Some(expected) = assertion.get("expectedValue") { + if evaluation + .variable_value + .as_ref() + .map(crate::VariableValue::to_json) + != Some(expected.clone()) + { + errors.push(format!( + "{variable_key}: expected value {expected}, got {:?}", + evaluation.variable_value + )); + } + } + if let Some(expected) = assertion + .get("expectedEvaluation") + .and_then(JsonValue::as_object) + { + let actual = serde_json::to_value(&evaluation).unwrap_or(JsonValue::Null); + compare_evaluation(&mut errors, variable_key, "variable", expected, &actual); + } + return Ok(errors); + } + let Some(feature_key) = feature_key else { - return Ok(vec!["test has neither feature nor segment".to_string()]); + return Ok(vec!["test has no feature, segment, or variable".to_string()]); }; let selected_key = datafile_key(environment, target); let datafile = datafiles @@ -418,7 +473,10 @@ fn run_assertion( let f = crate::create_featurevisor(FeaturevisorOptions { datafile: Some(input(datafile.clone())), context: Some(context), - sticky: Some(sticky), + sticky_features: Some(sticky), + sticky_variables: assertion + .get("stickyVariables") + .and_then(|value| serde_json::from_value(value.clone()).ok()), log_level: Some(log_level(options)), modules: vec![module], ..Default::default() diff --git a/src/evaluate.rs b/src/evaluate.rs index 8971a96..e7fa858 100644 --- a/src/evaluate.rs +++ b/src/evaluate.rs @@ -4,8 +4,9 @@ use crate::diagnostics::{Diagnostic, LogLevel}; use crate::helpers::panic_message; use crate::modules::{ConfigureBucketKeyOptions, ConfigureBucketValueOptions, FeaturevisorModule}; use crate::types::{ - Allocation, Context, DatafileContent, EvaluatedFeature, Feature, Force, Required, - ResolvedVariableSchema, StickyFeatures, Traffic, VariableOverride, VariableValue, Variation, + Allocation, Context, DatafileContent, EvaluatedFeature, Feature, Force, GlobalVariable, + Required, ResolvedVariableSchema, StickyFeatures, Traffic, VariableOverride, VariableValue, + Variation, }; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; @@ -48,6 +49,8 @@ pub enum EvaluationReason { VariableDefault, /// The variable was disabled. VariableDisabled, + /// One or more required features for a global variable were not satisfied. + RequiredFeaturesUnmet, /// The value came from a variation override. VariableOverrideVariation, /// The value came from a rule override. @@ -73,6 +76,7 @@ pub enum EvaluationReason { pub struct Evaluation { #[serde(rename = "type")] pub evaluation_type: EvaluationType, + #[serde(skip_serializing_if = "String::is_empty")] pub feature_key: String, pub reason: EvaluationReason, #[serde(skip_serializing_if = "Option::is_none")] @@ -92,7 +96,7 @@ pub struct Evaluation { #[serde(skip_serializing_if = "Option::is_none")] pub force: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub required: Option>, + pub required_features: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub sticky: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -106,7 +110,13 @@ pub struct Evaluation { #[serde(skip_serializing_if = "Option::is_none")] pub variable_schema: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub variable: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub variable_override_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub variable_override_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub variable_override_path: Option>, } #[derive(Clone)] @@ -167,7 +177,7 @@ impl EvaluationData { self.datafile.features.get(key).cloned() } - fn all_conditions( + pub(crate) fn all_conditions( &self, value: &JsonValue, context: &Context, @@ -281,6 +291,10 @@ pub(crate) fn evaluate_with_modules(mut options: EvaluateOptions) -> Evaluation for module in modules.iter() { options = module.before(options.clone()); } + let modules = Arc::clone(&options.modules); + for module in modules.iter() { + options = module.before_evaluation(options.clone()); + } let mut evaluation = evaluate(&options); if let Some(default) = options.default_variation_value.clone() { if evaluation.evaluation_type == EvaluationType::Variation @@ -297,6 +311,9 @@ pub(crate) fn evaluate_with_modules(mut options: EvaluateOptions) -> Evaluation evaluation.variable_value = Some(default); } } + for module in options.modules.iter() { + evaluation = module.after_evaluation(evaluation, &options); + } for module in options.modules.iter() { evaluation = module.after(evaluation, &options); } @@ -317,14 +334,17 @@ pub(crate) fn evaluate_with_modules(mut options: EvaluateOptions) -> Evaluation traffic: None, force_index: None, force: None, - required: None, + required_features: None, sticky: None, variation: None, variation_value: None, variable_key: original.variable_key.clone(), variable_value: None, variable_schema: None, + variable: None, variable_override_index: None, + variable_override_key: None, + variable_override_path: None, }; apply_diagnostic( original.report.as_ref(), @@ -364,14 +384,17 @@ fn evaluate(options: &EvaluateOptions) -> Evaluation { traffic: None, force_index: None, force: None, - required: None, + required_features: None, sticky: None, variation: None, variation_value: None, variable_key: variable_key.clone(), variable_value: None, variable_schema: None, + variable: None, variable_override_index: None, + variable_override_key: None, + variable_override_path: None, }; if let (EvaluationType::Variable, Some(variable_key)) = (type_, variable_key.as_ref()) @@ -429,14 +452,17 @@ fn evaluate(options: &EvaluateOptions) -> Evaluation { traffic: None, force_index: None, force: None, - required: None, + required_features: None, sticky: Some(sticky_feature.clone()), variation: None, variation_value: None, variable_key: None, variable_value: None, variable_schema: None, + variable: None, variable_override_index: None, + variable_override_key: None, + variable_override_path: None, }; apply_diagnostic( report, @@ -644,14 +670,18 @@ fn evaluate(options: &EvaluateOptions) -> Evaluation { } if type_ == EvaluationType::Flag { - if let Some(required) = &feature.required { + if let Some(required) = feature + .required_features + .as_ref() + .or(feature.required.as_ref()) + { if !required.is_empty() && !required .iter() .all(|required| required_is_met(required, options, data, report)) { let mut evaluation = basic(type_, key.clone(), EvaluationReason::Required); - evaluation.required = Some(required.clone()); + evaluation.required_features = Some(required.clone()); evaluation.enabled = Some(false); apply_diagnostic( report, @@ -863,6 +893,8 @@ fn evaluate(options: &EvaluateOptions) -> Evaluation { evaluation.rule_key = Some(traffic.key.clone()); evaluation.traffic = Some(traffic.clone()); evaluation.variable_override_index = Some(index); + evaluation.variable_override_key = override_value.key.clone(); + evaluation.variable_override_path = override_value.key_path.clone(); apply_diagnostic( report, &evaluation, @@ -942,6 +974,8 @@ fn evaluate(options: &EvaluateOptions) -> Evaluation { matched_traffic.as_ref().map(|traffic| traffic.key.clone()); evaluation.traffic = matched_traffic.clone(); evaluation.variable_override_index = Some(index); + evaluation.variable_override_key = override_value.key.clone(); + evaluation.variable_override_path = override_value.key_path.clone(); apply_diagnostic( report, &evaluation, @@ -1063,14 +1097,17 @@ fn basic( traffic: None, force_index: None, force: None, - required: None, + required_features: None, sticky: None, variation: None, variation_value: None, variable_key: None, variable_value: None, variable_schema: None, + variable: None, variable_override_index: None, + variable_override_key: None, + variable_override_path: None, } } fn basic_variation( @@ -1108,22 +1145,29 @@ fn required_is_met( data: &EvaluationData, report: &dyn Fn(Diagnostic), ) -> bool { - let (key, expected) = match required { - Required::Feature(key) => (key, None), - Required::Variation { key, variation } => (key, Some(variation.as_str())), + let (key, expected_enabled, expected) = match required { + Required::Feature(key) => (key, true, None), + Required::Details { + feature, + enabled, + variation, + } => (feature, enabled.unwrap_or(true), variation.as_deref()), + Required::LegacyVariation { key, variation } => (key, true, Some(variation.as_str())), }; let flag = evaluate(&EvaluateOptions { evaluation_type: EvaluationType::Flag, feature_key: key.clone(), + variable_key: None, ..options.clone() }); - if flag.enabled != Some(true) { + if (flag.enabled == Some(true)) != expected_enabled { return false; } if let Some(expected) = expected { let variation = evaluate(&EvaluateOptions { evaluation_type: EvaluationType::Variation, feature_key: key.clone(), + variable_key: None, ..options.clone() }); variation.variation_value.as_deref().or_else(|| { @@ -1145,13 +1189,24 @@ fn override_matches( data: &EvaluationData, report: &dyn Fn(Diagnostic), ) -> bool { - if let Some(conditions) = &item.conditions { - return data.all_conditions(conditions, &options.context, report); - } - if let Some(segments) = &item.segments { - return data.all_segments(segments, &options.context, report); + if item.required_features.as_ref().is_some_and(|requirements| { + !requirements + .iter() + .all(|required| required_is_met(required, options, data, report)) + }) { + return false; } - false + let conditions_match = item.conditions.as_ref().map_or(true, |conditions| { + data.all_conditions(conditions, &options.context, report) + }); + let segments_match = item.segments.as_ref().map_or(true, |segments| { + data.all_segments(segments, &options.context, report) + }); + conditions_match + && segments_match + && (item.conditions.is_some() + || item.segments.is_some() + || item.required_features.is_some()) } pub(crate) fn evaluate_all( diff --git a/src/events.rs b/src/events.rs index 6f1eb96..7614469 100644 --- a/src/events.rs +++ b/src/events.rs @@ -13,6 +13,8 @@ pub enum EventName { ContextSet, /// Sticky evaluations were merged or replaced. StickySet, + /// Sticky global variables were merged or replaced. + StickyVariablesSet, /// An error diagnostic was emitted. Error, } @@ -26,6 +28,7 @@ pub struct DatafileSetDetails { pub previous_revision: String, pub revision_changed: bool, pub features: Vec, + pub variables: Vec, pub replaced: bool, } @@ -47,6 +50,16 @@ pub struct StickySetDetails { pub replaced: bool, } +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Details emitted with a sticky global variable update event. +pub struct StickyVariablesSetDetails { + /// Global variable keys affected by the update. + pub variables: Vec, + /// Whether the previous sticky variable map was replaced. + pub replaced: bool, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] #[non_exhaustive] @@ -58,6 +71,8 @@ pub enum EventDetails { ContextSet(ContextSetDetails), /// Details for a sticky evaluation update. StickySet(StickySetDetails), + /// Details for a sticky global variable update. + StickyVariablesSet(StickyVariablesSetDetails), /// Details for an error diagnostic. Error { /// The diagnostic that caused the event. diff --git a/src/instance.rs b/src/instance.rs index e06617f..c1691bc 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -3,7 +3,7 @@ use crate::diagnostics::{Diagnostic, DiagnosticHandler, LogLevel}; use crate::emitter::Emitter; use crate::evaluate::{ evaluate_all, evaluate_with_modules, EvaluateOptions, Evaluation, EvaluationData, - EvaluationType, + EvaluationReason, EvaluationType, }; use crate::events::{ ContextSetDetails, DatafileSetDetails, EventDetails, EventHandler, EventName, StickySetDetails, @@ -11,11 +11,11 @@ use crate::events::{ use crate::helpers::panic_message; use crate::modules::{FeaturevisorModule, ModuleApi, ModuleSubscription}; use crate::types::{ - Context, DatafileContent, DatafileInput, EvaluatedFeatures, Feature, Segment, StickyFeatures, - VariableValue, + Context, DatafileContent, DatafileInput, EvaluatedFeatures, EvaluatedVariables, Feature, + Segment, StickyFeatures, StickyVariables, VariableValue, }; use serde_json::Value as JsonValue; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -34,7 +34,8 @@ pub struct OverrideOptions { /// Options used when creating a child evaluator. #[allow(missing_docs)] pub struct SpawnOptions { - pub sticky: Option, + pub sticky_features: Option, + pub sticky_variables: Option, } #[derive(Default)] @@ -45,7 +46,8 @@ pub struct FeaturevisorOptions { pub context: Option, pub log_level: Option, pub on_diagnostic: Option, - pub sticky: Option, + pub sticky_features: Option, + pub sticky_variables: Option, pub modules: Vec>, } @@ -58,7 +60,8 @@ struct ModuleRecord { struct Inner { datafile: Arc, context: Context, - sticky: StickyFeatures, + sticky_features: StickyFeatures, + sticky_variables: StickyVariables, log_level: LogLevel, on_diagnostic: Option, modules: Vec, @@ -73,6 +76,7 @@ type Snapshot = ( Arc, Context, StickyFeatures, + StickyVariables, LogLevel, Vec>, Arc>>, @@ -91,7 +95,8 @@ pub fn create_featurevisor(options: FeaturevisorOptions) -> Featurevisor { inner: Arc::new(Mutex::new(Inner { datafile: Arc::new(DatafileContent::default()), context: options.context.unwrap_or_default(), - sticky: options.sticky.unwrap_or_default(), + sticky_features: options.sticky_features.unwrap_or_default(), + sticky_variables: options.sticky_variables.unwrap_or_default(), log_level: options.log_level.unwrap_or_default(), on_diagnostic: options.on_diagnostic, modules: Vec::new(), @@ -122,7 +127,8 @@ impl Featurevisor { ( Arc::clone(&inner.datafile), inner.context.clone(), - inner.sticky.clone(), + inner.sticky_features.clone(), + inner.sticky_variables.clone(), inner.log_level, inner .modules @@ -347,6 +353,13 @@ impl Featurevisor { }) .unwrap_or_default() } + /// Returns the keys of all global variables in the current datafile. + pub fn get_global_variable_keys(&self) -> Vec { + self.inner + .lock() + .map(|inner| inner.datafile.variables.keys().cloned().collect()) + .unwrap_or_default() + } /// Returns whether a feature defines at least one variation. pub fn has_variations(&self, feature_key: &str) -> bool { self.get_feature(feature_key) @@ -356,7 +369,7 @@ impl Featurevisor { #[cfg(feature = "cli")] pub(crate) fn segment_matches(&self, segment_key: &str, context: &Context) -> bool { - let (datafile, _, _, _, _, regex_cache) = self.snapshot(); + let (datafile, _, _, _, _, _, regex_cache) = self.snapshot(); let data = EvaluationData { datafile, regex_cache, @@ -414,7 +427,7 @@ impl Featurevisor { /// Returns the stored context merged with an optional per evaluation context. pub fn get_context(&self, context: Option<&Context>) -> Context { - let (_, stored, _, _, _, _) = self.snapshot(); + let (_, stored, _, _, _, _, _) = self.snapshot(); let mut result = stored; if let Some(context) = context { result.extend(context.clone()); @@ -423,6 +436,11 @@ impl Featurevisor { } /// Updates sticky evaluations, either merging with or replacing them. pub fn set_sticky(&self, sticky: StickyFeatures, replace: bool) { + self.set_sticky_features(sticky, replace); + } + + /// Updates sticky feature evaluations, either merging with or replacing them. + pub fn set_sticky_features(&self, sticky: StickyFeatures, replace: bool) { let (features, emitter) = { let mut inner = match self.inner.lock() { Ok(inner) => inner, @@ -431,13 +449,13 @@ impl Featurevisor { if inner.closed { return; } - let mut features: Vec = inner.sticky.keys().cloned().collect(); + let mut features: Vec = inner.sticky_features.keys().cloned().collect(); if replace { - inner.sticky = sticky; + inner.sticky_features = sticky; } else { - inner.sticky.extend(sticky); + inner.sticky_features.extend(sticky); } - for key in inner.sticky.keys() { + for key in inner.sticky_features.keys() { if !features.contains(key) { features.push(key.clone()); } @@ -458,6 +476,39 @@ impl Featurevisor { emitter.emit(EventName::StickySet, EventDetails::StickySet(details)); } + /// Updates sticky global variables, either merging with or replacing them. + pub fn set_sticky_variables(&self, sticky: StickyVariables, replace: bool) { + let (variables, emitter) = { + let mut inner = match self.inner.lock() { + Ok(inner) => inner, + Err(_) => return, + }; + if inner.closed { + return; + } + let mut variables: Vec = inner.sticky_variables.keys().cloned().collect(); + if replace { + inner.sticky_variables = sticky; + } else { + inner.sticky_variables.extend(sticky); + } + for key in inner.sticky_variables.keys() { + if !variables.contains(key) { + variables.push(key.clone()); + } + } + (variables, inner.emitter.clone()) + }; + let details = crate::events::StickyVariablesSetDetails { + variables, + replaced: replace, + }; + emitter.emit( + EventName::StickyVariablesSet, + EventDetails::StickyVariablesSet(details), + ); + } + /// Registers a module and returns an idempotent cleanup function. pub fn add_module(&self, module: Arc) -> Option { let (id, name) = { @@ -580,7 +631,7 @@ impl Featurevisor { context: Option<&Context>, options: Option<&OverrideOptions>, ) -> EvaluateOptions { - let (datafile, stored_context, sticky, _, modules, regex_cache) = self.snapshot(); + let (datafile, stored_context, sticky, _, _, modules, regex_cache) = self.snapshot(); let mut evaluation_context = stored_context; if let Some(context) = context { evaluation_context.extend(context.clone()); @@ -787,8 +838,367 @@ impl Featurevisor { ) -> Option { self.get_variable(feature_key, variable_key, context, options) } + /// Evaluates a global variable and returns evaluation details. + pub fn evaluate_global_variable( + &self, + variable_key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Evaluation { + let sticky = self + .inner + .lock() + .map(|inner| inner.sticky_variables.clone()) + .unwrap_or_default(); + self.evaluate_global_variable_with_sticky(variable_key, context, options, sticky) + } + + pub(crate) fn evaluate_global_variable_with_sticky( + &self, + variable_key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + sticky: StickyVariables, + ) -> Evaluation { + match catch_unwind(AssertUnwindSafe(|| { + self.evaluate_global_variable_inner(variable_key, context, options, sticky) + })) { + Ok(evaluation) => evaluation, + Err(error) => { + let mut evaluation = self.empty_global_variable_evaluation(variable_key); + evaluation.reason = EvaluationReason::Error; + evaluation.error = Some(panic_message(error.as_ref())); + let mut diagnostic = Diagnostic::new( + LogLevel::Error, + "evaluation_error", + "Global variable evaluation failed", + ); + diagnostic.original_error = evaluation.error.clone(); + diagnostic.details.insert( + "evaluation".to_string(), + serde_json::to_value(&evaluation).unwrap_or(JsonValue::Null), + ); + self.report_diagnostic(diagnostic, None); + evaluation + } + } + } + + fn empty_global_variable_evaluation(&self, variable_key: &str) -> Evaluation { + Evaluation { + evaluation_type: EvaluationType::Variable, + feature_key: String::new(), + reason: EvaluationReason::VariableNotFound, + bucket_key: None, + bucket_value: None, + rule_key: None, + error: None, + enabled: None, + traffic: None, + force_index: None, + force: None, + required_features: None, + sticky: None, + variation: None, + variation_value: None, + variable_key: Some(variable_key.to_string()), + variable_value: None, + variable_schema: None, + variable: None, + variable_override_index: None, + variable_override_key: None, + variable_override_path: None, + } + } + + fn evaluate_global_variable_inner( + &self, + variable_key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + sticky: StickyVariables, + ) -> Evaluation { + let (datafile, stored_context, _, _, _, modules, regex_cache) = self.snapshot(); + let mut resolved_context = stored_context; + if let Some(context) = context { + resolved_context.extend(context.clone()); + } + let defaults = options.cloned().unwrap_or_default(); + let report: Arc = Arc::new({ + let instance = self.clone(); + move |diagnostic| instance.report_diagnostic(diagnostic, None) + }); + let data = Arc::new(EvaluationData { + datafile: Arc::clone(&datafile), + regex_cache, + }); + let mut evaluation_options = EvaluateOptions { + evaluation_type: EvaluationType::Variable, + feature_key: String::new(), + variable_key: Some(variable_key.to_string()), + context: resolved_context, + default_variation_value: defaults.default_variation_value, + default_variable_value: defaults.default_variable_value.clone(), + sticky: None, + data: Arc::clone(&data), + modules: Arc::new(modules.clone()), + report: Arc::clone(&report), + }; + for module in &modules { + evaluation_options = module.before_evaluation(evaluation_options); + } + let resolved_key = evaluation_options + .variable_key + .clone() + .unwrap_or_else(|| variable_key.to_string()); + let mut evaluation = self.empty_global_variable_evaluation(&resolved_key); + if let Some(value) = sticky.get(&resolved_key) { + evaluation.reason = EvaluationReason::Sticky; + evaluation.variable_value = Some(value.clone()); + } else if let Some(variable) = datafile.variables.get(&resolved_key) { + evaluation.variable = Some(variable.clone()); + let requirements_match = self.required_features_match( + variable.required_features.as_deref(), + &evaluation_options.context, + options, + ); + if !requirements_match { + evaluation.reason = EvaluationReason::RequiredFeaturesUnmet; + evaluation.variable_value = if variable.use_default_when_disabled == Some(true) { + variable.default_value.clone() + } else { + variable.disabled_value.clone() + }; + } else { + for (index, item) in variable.overrides.iter().enumerate() { + if !self.required_features_match( + item.required_features.as_deref(), + &evaluation_options.context, + options, + ) { + continue; + } + let conditions_match = item.conditions.as_ref().map_or(true, |conditions| { + data.all_conditions( + conditions, + &evaluation_options.context, + report.as_ref(), + ) + }); + let segments_match = item.segments.as_ref().map_or(true, |segments| { + data.all_segments(segments, &evaluation_options.context, report.as_ref()) + }); + if conditions_match && segments_match { + evaluation.reason = EvaluationReason::VariableOverrideRule; + evaluation.variable_value = Some(item.value.clone()); + evaluation.variable_override_index = Some(index); + evaluation.variable_override_key = item.key.clone(); + evaluation.variable_override_path = item.key_path.clone(); + break; + } + } + if evaluation.reason == EvaluationReason::VariableNotFound { + evaluation.reason = EvaluationReason::VariableDefault; + evaluation.variable_value = variable.default_value.clone(); + } + } + } + if evaluation.variable_value.is_none() { + evaluation.variable_value = defaults.default_variable_value; + } + for module in &modules { + evaluation = module.after_evaluation(evaluation, &evaluation_options); + } + if evaluation + .variable + .as_ref() + .is_some_and(|variable| variable.deprecated == Some(true)) + { + let mut diagnostic = Diagnostic::new( + LogLevel::Warn, + "variable_deprecated", + "Global variable is deprecated", + ); + diagnostic + .details + .insert("variableKey".to_string(), JsonValue::String(resolved_key)); + self.report_diagnostic(diagnostic, None); + } + let reason = serde_json::to_string(&evaluation.reason) + .unwrap_or_else(|_| "evaluation".to_string()) + .trim_matches('"') + .to_string(); + let mut diagnostic = Diagnostic::new(LogLevel::Debug, reason, "Global variable evaluated"); + diagnostic.details = serde_json::to_value(&evaluation) + .ok() + .and_then(|value| value.as_object().cloned()) + .map(|value| value.into_iter().collect()) + .unwrap_or_default(); + self.report_diagnostic(diagnostic, None); + evaluation + } + + fn required_features_match( + &self, + requirements: Option<&[crate::types::Required]>, + context: &Context, + options: Option<&OverrideOptions>, + ) -> bool { + requirements.unwrap_or_default().iter().all(|required| { + let (key, enabled, variation) = match required { + crate::types::Required::Feature(key) => (key.as_str(), true, None), + crate::types::Required::Details { + feature, + enabled, + variation, + } => ( + feature.as_str(), + enabled.unwrap_or(true), + variation.as_deref(), + ), + crate::types::Required::LegacyVariation { key, variation } => { + (key.as_str(), true, Some(variation.as_str())) + } + }; + if self.is_enabled(key, Some(context)) != enabled { + return false; + } + variation.map_or(true, |expected| { + self.get_variation(key, Some(context), options).as_deref() == Some(expected) + }) + }) + } + + /// Returns a global variable value, if one is available. + pub fn get_global_variable( + &self, + variable_key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option { + let evaluation = self.evaluate_global_variable(variable_key, context, options); + let is_json = evaluation + .variable + .as_ref() + .map(|variable| variable.variable_type == "json") + .unwrap_or(false); + let value = evaluation.variable_value?; + if is_json { + if let VariableValue::String(value) = value { + return serde_json::from_str::(&value) + .ok() + .map(VariableValue::from_json); + } + } + Some(value) + } + /// Returns a global variable as a boolean when its value has that type. + pub fn get_global_variable_boolean( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option { + match self.get_global_variable(key, context, options)? { + VariableValue::Boolean(value) => Some(value), + _ => None, + } + } + /// Returns a global variable as a string when its value has that type. + pub fn get_global_variable_string( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option { + match self.get_global_variable(key, context, options)? { + VariableValue::String(value) => Some(value), + _ => None, + } + } + /// Returns a global variable as an integer when its value has that type. + pub fn get_global_variable_integer( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option { + match self.get_global_variable(key, context, options)? { + VariableValue::Integer(value) => Some(value), + VariableValue::Double(value) if value.is_finite() && value.fract() == 0.0 => { + Some(value as i64) + } + _ => None, + } + } + /// Returns a global variable as a double when its value has that type. + pub fn get_global_variable_double( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option { + match self.get_global_variable(key, context, options)? { + VariableValue::Integer(value) => Some(value as f64), + VariableValue::Double(value) if value.is_finite() => Some(value), + _ => None, + } + } + /// Returns a global variable as an array when its value has that type. + pub fn get_global_variable_array( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option> { + match self.get_global_variable(key, context, options)? { + VariableValue::Array(value) => Some(value), + _ => None, + } + } + /// Returns a global variable as an object when its value has that type. + pub fn get_global_variable_object( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option> { + match self.get_global_variable(key, context, options)? { + VariableValue::Object(value) => Some(value), + _ => None, + } + } + /// Returns a global variable value without imposing a more specific Rust type. + pub fn get_global_variable_json( + &self, + key: &str, + context: Option<&Context>, + options: Option<&OverrideOptions>, + ) -> Option { + self.get_global_variable(key, context, options) + } + + /// Evaluates requested global variables, or every global variable when no keys are supplied. + pub fn get_global_variable_evaluations( + &self, + context: Option<&Context>, + variable_keys: &[String], + options: Option<&OverrideOptions>, + ) -> EvaluatedVariables { + let keys = if variable_keys.is_empty() { + self.get_global_variable_keys() + } else { + variable_keys.to_vec() + }; + keys.into_iter() + .filter_map(|key| { + self.get_global_variable(&key, context, options) + .map(|value| (key, value)) + }) + .collect() + } /// Evaluates all requested features, or every feature when no keys are supplied. - pub fn get_all_evaluations( + pub fn get_feature_evaluations( &self, context: Option<&Context>, feature_keys: &[String], @@ -802,12 +1212,23 @@ impl Featurevisor { }; evaluate_all(&eval_options, &keys) } + /// Deprecated alias for [`Featurevisor::get_feature_evaluations`]. + #[deprecated(note = "use get_feature_evaluations")] + pub fn get_all_evaluations( + &self, + context: Option<&Context>, + feature_keys: &[String], + options: Option<&OverrideOptions>, + ) -> EvaluatedFeatures { + self.get_feature_evaluations(context, feature_keys, options) + } /// Creates a child evaluator with its own context and sticky state. pub fn spawn(&self, context: Context, options: SpawnOptions) -> FeaturevisorChild { FeaturevisorChild::new( self.clone(), self.get_context(Some(&context)), - options.sticky.unwrap_or_default(), + options.sticky_features.unwrap_or_default(), + options.sticky_variables.unwrap_or_default(), ) } /// Closes the instance, modules, subscriptions, and event listeners. @@ -899,6 +1320,11 @@ fn merge_datafile(previous: &DatafileContent, incoming: DatafileContent) -> Data featurevisor_version: incoming.featurevisor_version, segments, features, + variables: { + let mut variables = previous.variables.clone(); + variables.extend(incoming.variables); + variables + }, } } fn datafile_details( @@ -910,7 +1336,9 @@ fn datafile_details( for (key, old) in &previous.features { match next.features.get(key) { None => features.push(key.clone()), - Some(new) if old.hash != new.hash => features.push(key.clone()), + Some(new) if old.hash.is_none() || new.hash.is_none() || old.hash != new.hash => { + features.push(key.clone()) + } _ => {} } } @@ -919,11 +1347,136 @@ fn datafile_details( features.push(key.clone()); } } + let mut variables = Vec::new(); + for (key, old) in &previous.variables { + match next.variables.get(key) { + None => variables.push(key.clone()), + Some(new) if old.hash.is_none() || new.hash.is_none() || old.hash != new.hash => { + variables.push(key.clone()) + } + _ => {} + } + } + for key in next.variables.keys() { + if !previous.variables.contains_key(key) { + variables.push(key.clone()); + } + } + let changed_segments: HashSet = previous + .segments + .keys() + .chain(next.segments.keys()) + .filter(|key| previous.segments.get(*key) != next.segments.get(*key)) + .cloned() + .collect(); + let mut changed_features: HashSet = features.iter().cloned().collect(); + let mut changed_variables: HashSet = variables.iter().cloned().collect(); + loop { + let mut progressed = false; + for graph in [previous, next] { + for (key, feature) in &graph.features { + if changed_features.contains(key) { + continue; + } + let value = serde_json::to_value(feature).unwrap_or(JsonValue::Null); + let mut required = HashSet::new(); + collect_reference_values(&value, "requiredFeatures", &mut required); + collect_reference_values(&value, "required", &mut required); + let mut segments = HashSet::new(); + collect_reference_values(&value, "segments", &mut segments); + if required + .iter() + .any(|dependency| changed_features.contains(dependency)) + || segments + .iter() + .any(|dependency| changed_segments.contains(dependency)) + { + changed_features.insert(key.clone()); + progressed = true; + } + } + for (key, variable) in &graph.variables { + if changed_variables.contains(key) { + continue; + } + let value = serde_json::to_value(variable).unwrap_or(JsonValue::Null); + let mut required = HashSet::new(); + collect_reference_values(&value, "requiredFeatures", &mut required); + let mut segments = HashSet::new(); + collect_reference_values(&value, "segments", &mut segments); + if required + .iter() + .any(|dependency| changed_features.contains(dependency)) + || segments + .iter() + .any(|dependency| changed_segments.contains(dependency)) + { + changed_variables.insert(key.clone()); + progressed = true; + } + } + } + if !progressed { + break; + } + } + features = changed_features.into_iter().collect(); + variables = changed_variables.into_iter().collect(); + features.sort(); + variables.sort(); DatafileSetDetails { revision: next.revision.clone(), previous_revision: previous.revision.clone(), revision_changed: previous.revision != next.revision, features, + variables, replaced, } } + +fn collect_reference_values(value: &JsonValue, field: &str, output: &mut HashSet) { + match value { + JsonValue::Object(object) => { + for (key, value) in object { + if key == field { + collect_reference_expression(value, output); + } else { + collect_reference_values(value, field, output); + } + } + } + JsonValue::Array(values) => { + for value in values { + collect_reference_values(value, field, output); + } + } + _ => {} + } +} + +fn collect_reference_expression(value: &JsonValue, output: &mut HashSet) { + match value { + JsonValue::String(value) if value != "*" => { + output.insert(value.clone()); + } + JsonValue::Array(values) => { + for value in values { + collect_reference_expression(value, output); + } + } + JsonValue::Object(object) => { + if let Some(value) = object + .get("feature") + .or_else(|| object.get("key")) + .and_then(JsonValue::as_str) + { + output.insert(value.to_string()); + } else { + for value in object.values() { + collect_reference_expression(value, output); + } + } + } + _ => {} + } +} diff --git a/src/lib.rs b/src/lib.rs index 5fef922..24327d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,6 +57,7 @@ pub use diagnostics::{Diagnostic, DiagnosticHandler, LogLevel}; pub use evaluate::{EvaluateOptions, Evaluation, EvaluationReason, EvaluationType}; pub use events::{ ContextSetDetails, DatafileSetDetails, EventDetails, EventHandler, EventName, StickySetDetails, + StickyVariablesSetDetails, }; pub use instance::{ create_featurevisor, Featurevisor, FeaturevisorOptions, OverrideOptions, SpawnOptions, @@ -66,10 +67,11 @@ pub use modules::{ }; pub use types::{ Allocation, AndCondition, AndGroupSegment, AttributeValue, BucketBy, Condition, Context, - DatafileContent, DatafileInput, EvaluatedFeature, EvaluatedFeatures, Feature, FeatureKey, - Force, GroupSegment, NotCondition, NotGroupSegment, Operator, OrCondition, OrGroupSegment, - PlainCondition, Required, ResolvedVariableSchema, RuleKey, Segment, SegmentKey, StickyFeatures, - Traffic, VariableOverride, VariableValue, Variation, VariationValue, + DatafileContent, DatafileInput, EvaluatedFeature, EvaluatedFeatures, EvaluatedVariables, + Feature, FeatureKey, Force, GlobalVariable, GroupSegment, NotCondition, NotGroupSegment, + Operator, OrCondition, OrGroupSegment, PlainCondition, Required, ResolvedVariableSchema, + RuleKey, Segment, SegmentKey, StickyFeatures, StickyVariables, Traffic, VariableOverride, + VariableValue, Variation, VariationValue, }; /// A one shot cleanup callback returned by subscriptions and module registration. diff --git a/src/modules.rs b/src/modules.rs index 228758f..614aed2 100644 --- a/src/modules.rs +++ b/src/modules.rs @@ -39,6 +39,11 @@ pub trait FeaturevisorModule: Send + Sync { options } + /// Transforms options before any feature or global variable evaluation. + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + options + } + /// Transforms the bucket key used for an evaluation. fn bucket_key(&self, options: ConfigureBucketKeyOptions) -> String { options.bucket_key @@ -54,6 +59,12 @@ pub trait FeaturevisorModule: Send + Sync { evaluation } + /// Transforms a result after any feature or global variable evaluation. + fn after_evaluation(&self, evaluation: Evaluation, options: &EvaluateOptions) -> Evaluation { + let _ = options; + evaluation + } + /// Releases module resources when the module is removed or the instance closes. fn close(&self) {} } diff --git a/src/types.rs b/src/types.rs index baeff67..af7c14f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -9,6 +9,10 @@ use std::collections::HashMap; pub type Context = HashMap; /// Sticky evaluation results keyed by feature key. pub type StickyFeatures = HashMap; +/// Sticky global variable values keyed by variable key. +pub type StickyVariables = HashMap; +/// Global variable evaluation results keyed by variable key. +pub type EvaluatedVariables = HashMap; /// Evaluation results keyed by feature key. pub type EvaluatedFeatures = HashMap; /// A variation value. @@ -433,6 +437,8 @@ pub struct DatafileContent { pub featurevisor_version: Option, pub segments: HashMap, pub features: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub variables: HashMap, } impl Default for DatafileContent { @@ -443,6 +449,7 @@ impl Default for DatafileContent { featurevisor_version: None, segments: HashMap::new(), features: HashMap::new(), + variables: HashMap::new(), } } } @@ -475,7 +482,17 @@ impl Default for BucketBy { /// A feature dependency requirement. pub enum Required { Feature(String), - Variation { key: String, variation: String }, + Details { + feature: String, + #[serde(skip_serializing_if = "Option::is_none")] + enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + variation: Option, + }, + LegacyVariation { + key: String, + variation: String, + }, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -519,9 +536,39 @@ impl Default for ResolvedVariableSchema { pub struct VariableOverride { pub value: VariableValue, #[serde(skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "keyPath")] + pub key_path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub conditions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub segments: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "requiredFeatures")] + pub required_features: Option>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// A global variable definition from a datafile. +pub struct GlobalVariable { + #[serde(skip_serializing_if = "Option::is_none")] + pub hash: Option, + #[serde(rename = "type")] + pub variable_type: String, + #[serde(rename = "defaultValue", skip_serializing_if = "Option::is_none")] + pub default_value: Option, + #[serde(rename = "disabledValue", skip_serializing_if = "Option::is_none")] + pub disabled_value: Option, + #[serde( + rename = "useDefaultWhenDisabled", + skip_serializing_if = "Option::is_none" + )] + pub use_default_when_disabled: Option, + #[serde(rename = "requiredFeatures", skip_serializing_if = "Option::is_none")] + pub required_features: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub overrides: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub deprecated: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -587,6 +634,8 @@ pub struct Feature { pub deprecated: Option, #[serde(skip_serializing_if = "Option::is_none")] pub required: Option>, + #[serde(skip_serializing_if = "Option::is_none", rename = "requiredFeatures")] + pub required_features: Option>, #[serde(skip_serializing_if = "Option::is_none", rename = "variablesSchema")] pub variables_schema: Option>, #[serde( diff --git a/tests/child.rs b/tests/child.rs index cf01f88..a1b91d1 100644 --- a/tests/child.rs +++ b/tests/child.rs @@ -1,4 +1,7 @@ -use featurevisor::{create_featurevisor, AttributeValue, DatafileInput, FeaturevisorOptions}; +use featurevisor::{ + create_featurevisor, AttributeValue, DatafileInput, FeaturevisorOptions, SpawnOptions, + StickyVariables, +}; use serde_json::json; #[test] @@ -37,3 +40,48 @@ fn child_keeps_a_context_snapshot_and_inherits_new_parent_keys() { child.close(); child.close(); } + +#[test] +fn child_global_variable_sticky_state_is_isolated() { + let datafile = serde_json::from_value(json!({ + "schemaVersion": "2", "revision": "child-variables", "segments": {}, "features": {}, + "variables": { "message": { "type": "string", "defaultValue": "default" } } + })) + .unwrap(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile)), + sticky_variables: Some(StickyVariables::from([( + "message".to_string(), + "parent".into(), + )])), + ..Default::default() + }); + let child = f.spawn( + Default::default(), + SpawnOptions { + sticky_variables: Some(StickyVariables::from([( + "message".to_string(), + "child".into(), + )])), + ..Default::default() + }, + ); + let plain_child = f.spawn(Default::default(), Default::default()); + assert_eq!( + f.get_global_variable_string("message", None, None) + .as_deref(), + Some("parent") + ); + assert_eq!( + child + .get_global_variable_string("message", None, None) + .as_deref(), + Some("child") + ); + assert_eq!( + plain_child + .get_global_variable_string("message", None, None) + .as_deref(), + Some("default") + ); +} diff --git a/tests/conformance.rs b/tests/conformance.rs index 55c4c4c..070e33e 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -64,14 +64,16 @@ fn condition_feature(condition: Value) -> featurevisor::DatafileContent { } #[test] -fn fixture_is_version_two_and_every_section_is_present() { +fn fixture_is_version_five_and_every_section_is_present() { let fixture: Value = serde_json::from_str(include_str!("../conformance/sdk-v3.json")).unwrap(); - assert_eq!(fixture["version"], 2); + assert_eq!(fixture["version"], 5); for section in [ "bucketing", "regularExpressions", "typedVariables", "datafile", + "globalVariables", + "requiredFeatures", "diagnostics", "numericBucketKeys", "portableConditions", @@ -107,6 +109,205 @@ fn fixture_is_version_two_and_every_section_is_present() { } } +#[test] +fn global_variables_and_required_features_match_the_canonical_fixture() { + let fixture: Value = serde_json::from_str(include_str!("../conformance/sdk-v3.json")).unwrap(); + let global = &fixture["globalVariables"]; + for case in global["cases"].as_array().unwrap() { + let mut options = FeaturevisorOptions { + datafile: Some(DatafileInput::Content( + serde_json::from_value(global["datafile"].clone()).unwrap(), + )), + ..Default::default() + }; + if let Some(sticky) = case.get("stickyVariables") { + options.sticky_variables = Some(serde_json::from_value(sticky.clone()).unwrap()); + } + let f = create_featurevisor(options); + let context: Context = case + .get("context") + .and_then(Value::as_object) + .map(|values| { + values + .iter() + .map(|(key, value)| (key.clone(), AttributeValue::from_json(value.clone()))) + .collect() + }) + .unwrap_or_default(); + let override_options = featurevisor::OverrideOptions { + default_variable_value: case + .get("defaultVariableValue") + .cloned() + .map(featurevisor::VariableValue::from_json), + ..Default::default() + }; + let evaluation = f.evaluate_global_variable( + case["key"].as_str().unwrap(), + Some(&context), + Some(&override_options), + ); + assert_eq!( + serde_json::to_value(&evaluation.reason).unwrap(), + case["expectedReason"], + "{}", + case["name"] + ); + if let Some(expected) = case.get("expectedValue") { + assert_eq!( + evaluation + .variable_value + .as_ref() + .map(|value| value.to_json()), + Some(expected.clone()), + "{}", + case["name"] + ); + } else { + assert!(evaluation.variable_value.is_none(), "{}", case["name"]); + } + assert_eq!( + evaluation + .variable_override_index + .map(|value| Value::from(value as u64)) + .as_ref(), + case.get("expectedOverrideIndex"), + "{}", + case["name"] + ); + assert_eq!( + evaluation.variable_override_key.as_deref(), + case.get("expectedOverrideKey").and_then(Value::as_str), + "{}", + case["name"] + ); + if let Some(expected) = case.get("expectedOverridePath") { + assert_eq!( + serde_json::to_value(&evaluation.variable_override_path).unwrap(), + *expected, + "{}", + case["name"] + ); + } + } + + let required = &fixture["requiredFeatures"]; + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content( + serde_json::from_value(required["datafile"].clone()).unwrap(), + )), + ..Default::default() + }); + for case in required["cases"].as_array().unwrap() { + assert_eq!( + f.is_enabled(case["feature"].as_str().unwrap(), None), + case["expectedEnabled"].as_bool().unwrap(), + "{}", + case["name"] + ); + } + let variable_case = &required["featureVariableCase"]; + assert!(f.is_enabled("enabledFeature", None)); + let evaluation = f.evaluate_variable( + variable_case["feature"].as_str().unwrap(), + variable_case["variable"].as_str().unwrap(), + None, + None, + ); + assert_eq!( + evaluation.variable_value.map(|value| value.to_json()), + Some(variable_case["expectedValue"].clone()) + ); + assert_eq!( + evaluation.variable_override_key.as_deref(), + variable_case["expectedOverrideKey"].as_str() + ); +} + +#[test] +fn global_variable_datafile_events_include_direct_and_dependency_changes() { + let fixture: Value = serde_json::from_str(include_str!("../conformance/sdk-v3.json")).unwrap(); + for (case_key, expected_key, replace) in [ + ("merge", "expectedAfterMerge", false), + ("replacement", "expectedAfterReplacement", true), + ] { + let update = &fixture["globalVariables"]["datafileUpdateCase"]; + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content( + serde_json::from_value(update["initial"].clone()).unwrap(), + )), + ..Default::default() + }); + if replace { + f.set_datafile( + DatafileInput::Content(serde_json::from_value(update["merge"].clone()).unwrap()), + false, + ); + } + let observed = Arc::new(Mutex::new(None)); + let copy = Arc::clone(&observed); + let _unsubscribe = f.on( + EventName::DatafileSet, + Arc::new(move |event| { + if let EventDetails::DatafileSet(details) = event { + *copy.lock().unwrap() = Some(details.clone()); + } + }), + ); + f.set_datafile( + DatafileInput::Content(serde_json::from_value(update[case_key].clone()).unwrap()), + replace, + ); + let details = observed.lock().unwrap().clone().unwrap(); + let mut features = details.features; + let mut variables = details.variables; + features.sort(); + variables.sort(); + let mut expected_features: Vec = + serde_json::from_value(update[expected_key]["changedFeatures"].clone()).unwrap(); + let mut expected_variables: Vec = + serde_json::from_value(update[expected_key]["changedVariables"].clone()).unwrap(); + expected_features.sort(); + expected_variables.sort(); + assert_eq!(features, expected_features, "{case_key}"); + assert_eq!(variables, expected_variables, "{case_key}"); + } + + let dependency = &fixture["globalVariables"]["dependencyUpdateCase"]; + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content( + serde_json::from_value(dependency["initial"].clone()).unwrap(), + )), + ..Default::default() + }); + let observed = Arc::new(Mutex::new(None)); + let copy = Arc::clone(&observed); + let _unsubscribe = f.on( + EventName::DatafileSet, + Arc::new(move |event| { + if let EventDetails::DatafileSet(details) = event { + *copy.lock().unwrap() = Some(details.clone()); + } + }), + ); + f.set_datafile( + DatafileInput::Content(serde_json::from_value(dependency["updated"].clone()).unwrap()), + true, + ); + let details = observed.lock().unwrap().clone().unwrap(); + let mut features = details.features; + features.sort(); + let mut variables = details.variables; + variables.sort(); + let mut expected_features: Vec = + serde_json::from_value(dependency["expectedChangedFeatures"].clone()).unwrap(); + expected_features.sort(); + let mut expected_variables: Vec = + serde_json::from_value(dependency["expectedChangedVariables"].clone()).unwrap(); + expected_variables.sort(); + assert_eq!(features, expected_features); + assert_eq!(variables, expected_variables); +} + #[test] fn fixture_bucketing_numbers_regex_and_typed_values_are_executed() { let fixture: Value = serde_json::from_str(include_str!("../conformance/sdk-v3.json")).unwrap(); diff --git a/tests/modules.rs b/tests/modules.rs index 89afc7b..1b148ef 100644 --- a/tests/modules.rs +++ b/tests/modules.rs @@ -11,6 +11,28 @@ struct TestModule { struct FailingCloseModule; +struct GlobalVariableModule; + +impl FeaturevisorModule for GlobalVariableModule { + fn before_evaluation(&self, mut options: EvaluateOptions) -> EvaluateOptions { + if options.feature_key.is_empty() { + options.context.insert("country".to_string(), "nl".into()); + } + options + } + + fn after_evaluation( + &self, + mut evaluation: Evaluation, + _options: &EvaluateOptions, + ) -> Evaluation { + if evaluation.feature_key.is_empty() { + evaluation.variable_value = Some("after".into()); + } + evaluation + } +} + impl FeaturevisorModule for FailingCloseModule { fn name(&self) -> Option<&str> { Some("failing-close") @@ -118,3 +140,23 @@ fn module_close_failures_include_module_metadata() { assert_eq!(diagnostic.module_name.as_deref(), Some("failing-close")); assert!(diagnostic.original_error.is_some()); } + +#[test] +fn unified_module_callbacks_apply_to_global_variables() { + let datafile = serde_json::from_value(json!({ + "schemaVersion": "2", "revision": "global", "segments": {}, "features": {}, + "variables": { "message": { "type": "string", "defaultValue": "default", "overrides": [ + { "key": "nl", "conditions": { "attribute": "country", "operator": "equals", "value": "nl" }, "value": "matched" } + ] } } + })).unwrap(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile)), + modules: vec![Arc::new(GlobalVariableModule)], + ..Default::default() + }); + assert_eq!( + f.get_global_variable_string("message", None, None) + .as_deref(), + Some("after") + ); +} diff --git a/tests/public_api.rs b/tests/public_api.rs index b2a87ad..abe832a 100644 --- a/tests/public_api.rs +++ b/tests/public_api.rs @@ -29,5 +29,6 @@ fn primary_public_api_is_usable_by_an_external_consumer() { let _attribute = AttributeValue::from(vec!["one", "two"]); let _ = f.get_revision(); let _ = f.get_schema_version(); - let _ = f.get_all_evaluations(None, &[], None); + let _ = f.get_feature_evaluations(None, &[], None); + let _ = f.get_global_variable_evaluations(None, &[], None); } From d2b12128fe17c6972714a9e8e2afa80ebc029af3 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 29 Aug 2026 01:03:59 +0200 Subject: [PATCH 2/2] conformance --- README.md | 10 ++-- conformance/sdk-v3.json | 51 +++++++++++++++++++- src/child.rs | 33 +++++-------- src/evaluate.rs | 17 ++----- src/events.rs | 8 ++-- src/instance.rs | 41 ++++++++-------- src/lib.rs | 4 +- tests/conformance.rs | 4 +- tests/modules.rs | 103 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 204 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 961f522..2fa4530 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The SDK supports Featurevisor v3 projects and schema version 2 datafiles. The li - [Events](#events) - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - - [`sticky_set`](#sticky_set) + - [`sticky_features_set` and `sticky_variables_set`](#sticky_features_set-and-sticky_variables_set) - [`error`](#error) - [Evaluation details](#evaluation-details) - [Modules](#modules) @@ -221,7 +221,7 @@ let features = f.get_feature_evaluations(None, &[], None); let variables = f.get_global_variable_evaluations(None, &[], None); ``` -Pass a list of keys to limit either result. An empty list evaluates every entity of that kind in the datafile. `get_all_evaluations` is a deprecated alias for `get_feature_evaluations`. +Pass a list of keys to limit either result. An empty list evaluates every entity of that kind in the datafile. ## Sticky @@ -330,9 +330,9 @@ Emitted after a valid datafile is stored. Details include `revision`, `previousR Emitted after context is merged or replaced. Details include `context` and `replaced`. -### `sticky_set` +### `sticky_features_set` and `sticky_variables_set` -Emitted after sticky features are merged or replaced. Details include `features` and `replaced`. +`sticky_features_set` is emitted after sticky features are merged or replaced. Details include `features` and `replaced`. `sticky_variables_set` is emitted after sticky global variables are merged or replaced. Its details include `variables` and `replaced`. @@ -398,7 +398,7 @@ f.remove_module("audit"); drop(unsubscribe); ``` -Modules run `before_evaluation` callbacks in registration order for feature and global variable evaluations, then bucket key and bucket value callbacks during feature bucketing, and finally `after_evaluation` callbacks in registration order. The older `before` and `after` callbacks remain feature only compatibility callbacks. Duplicate names are reported and ignored. +For feature evaluations, all `before` callbacks run in registration order, followed by all `before_evaluation` callbacks. After evaluation and caller defaults, all `after_evaluation` callbacks run, followed by all `after` callbacks. Global variable evaluations use only `before_evaluation` and `after_evaluation`. Required feature checks run through the complete module pipeline, and transformed defaults are preserved. Bucket key and bucket value callbacks run during feature bucketing. Duplicate names are reported and ignored. ## Child instance diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json index 682a73d..d5870a5 100644 --- a/conformance/sdk-v3.json +++ b/conformance/sdk-v3.json @@ -1,5 +1,5 @@ { - "version": 5, + "version": 6, "description": "Featurevisor v3 cross SDK compatibility contracts", "bucketing": { "minimum": 0, @@ -787,11 +787,28 @@ "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": { @@ -814,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/src/child.rs b/src/child.rs index c892cb0..995fca1 100644 --- a/src/child.rs +++ b/src/child.rs @@ -1,6 +1,8 @@ use crate::emitter::Emitter; use crate::evaluate::{Evaluation, EvaluationType}; -use crate::events::{ContextSetDetails, EventDetails, EventHandler, EventName, StickySetDetails}; +use crate::events::{ + ContextSetDetails, EventDetails, EventHandler, EventName, StickyFeaturesSetDetails, +}; use crate::instance::{Featurevisor, OverrideOptions}; use crate::types::{ Context, EvaluatedFeatures, EvaluatedVariables, StickyFeatures, StickyVariables, VariableValue, @@ -91,11 +93,6 @@ impl FeaturevisorChild { } self.parent.get_context(Some(&merged)) } - /// Updates sticky evaluations used by this child. - pub fn set_sticky(&self, sticky: StickyFeatures, replace: bool) { - self.set_sticky_features(sticky, replace); - } - /// Updates sticky feature evaluations used by this child. pub fn set_sticky_features(&self, sticky: StickyFeatures, replace: bool) { let (features, emitter) = { @@ -117,8 +114,8 @@ impl FeaturevisorChild { ) }; emitter.emit( - EventName::StickySet, - EventDetails::StickySet(StickySetDetails { + EventName::StickyFeaturesSet, + EventDetails::StickyFeaturesSet(StickyFeaturesSetDetails { features, replaced: replace, }), @@ -158,7 +155,7 @@ impl FeaturevisorChild { pub fn on(&self, event: EventName, callback: EventHandler) -> Unsubscribe { if matches!( event, - EventName::ContextSet | EventName::StickySet | EventName::StickyVariablesSet + EventName::ContextSet | EventName::StickyFeaturesSet | EventName::StickyVariablesSet ) { return self .inner @@ -406,18 +403,12 @@ impl FeaturevisorChild { if let Some(context) = context { merged.extend(context.clone()); } - self.parent - .get_all_evaluations_with_sticky(Some(&merged), feature_keys, options, sticky) - } - /// Deprecated alias for [`FeaturevisorChild::get_feature_evaluations`]. - #[deprecated(note = "use get_feature_evaluations")] - pub fn get_all_evaluations( - &self, - context: Option<&Context>, - feature_keys: &[String], - options: Option<&OverrideOptions>, - ) -> EvaluatedFeatures { - self.get_feature_evaluations(context, feature_keys, options) + self.parent.get_feature_evaluations_with_sticky( + Some(&merged), + feature_keys, + options, + sticky, + ) } /// Evaluates a global variable and returns evaluation details. diff --git a/src/evaluate.rs b/src/evaluate.rs index e7fa858..9064eaa 100644 --- a/src/evaluate.rs +++ b/src/evaluate.rs @@ -678,7 +678,7 @@ fn evaluate(options: &EvaluateOptions) -> Evaluation { if !required.is_empty() && !required .iter() - .all(|required| required_is_met(required, options, data, report)) + .all(|required| required_is_met(required, options)) { let mut evaluation = basic(type_, key.clone(), EvaluationReason::Required); evaluation.required_features = Some(required.clone()); @@ -1139,12 +1139,7 @@ fn format_reason(reason: &EvaluationReason) -> String { .to_string() } -fn required_is_met( - required: &Required, - options: &EvaluateOptions, - data: &EvaluationData, - report: &dyn Fn(Diagnostic), -) -> bool { +fn required_is_met(required: &Required, options: &EvaluateOptions) -> bool { let (key, expected_enabled, expected) = match required { Required::Feature(key) => (key, true, None), Required::Details { @@ -1154,7 +1149,7 @@ fn required_is_met( } => (feature, enabled.unwrap_or(true), variation.as_deref()), Required::LegacyVariation { key, variation } => (key, true, Some(variation.as_str())), }; - let flag = evaluate(&EvaluateOptions { + let flag = evaluate_with_modules(EvaluateOptions { evaluation_type: EvaluationType::Flag, feature_key: key.clone(), variable_key: None, @@ -1164,7 +1159,7 @@ fn required_is_met( return false; } if let Some(expected) = expected { - let variation = evaluate(&EvaluateOptions { + let variation = evaluate_with_modules(EvaluateOptions { evaluation_type: EvaluationType::Variation, feature_key: key.clone(), variable_key: None, @@ -1177,8 +1172,6 @@ fn required_is_met( .map(|value| value.value.as_str()) }) == Some(expected) } else { - let _ = data; - let _ = report; true } } @@ -1192,7 +1185,7 @@ fn override_matches( if item.required_features.as_ref().is_some_and(|requirements| { !requirements .iter() - .all(|required| required_is_met(required, options, data, report)) + .all(|required| required_is_met(required, options)) }) { return false; } diff --git a/src/events.rs b/src/events.rs index 7614469..f5ee0f1 100644 --- a/src/events.rs +++ b/src/events.rs @@ -11,8 +11,8 @@ pub enum EventName { DatafileSet, /// Stored context was merged or replaced. ContextSet, - /// Sticky evaluations were merged or replaced. - StickySet, + /// Sticky feature evaluations were merged or replaced. + StickyFeaturesSet, /// Sticky global variables were merged or replaced. StickyVariablesSet, /// An error diagnostic was emitted. @@ -45,7 +45,7 @@ pub struct ContextSetDetails { #[serde(rename_all = "camelCase")] /// Details emitted with a sticky evaluation update event. #[allow(missing_docs)] -pub struct StickySetDetails { +pub struct StickyFeaturesSetDetails { pub features: Vec, pub replaced: bool, } @@ -70,7 +70,7 @@ pub enum EventDetails { /// Details for a context update. ContextSet(ContextSetDetails), /// Details for a sticky evaluation update. - StickySet(StickySetDetails), + StickyFeaturesSet(StickyFeaturesSetDetails), /// Details for a sticky global variable update. StickyVariablesSet(StickyVariablesSetDetails), /// Details for an error diagnostic. diff --git a/src/instance.rs b/src/instance.rs index c1691bc..4b2d994 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -6,7 +6,8 @@ use crate::evaluate::{ EvaluationReason, EvaluationType, }; use crate::events::{ - ContextSetDetails, DatafileSetDetails, EventDetails, EventHandler, EventName, StickySetDetails, + ContextSetDetails, DatafileSetDetails, EventDetails, EventHandler, EventName, + StickyFeaturesSetDetails, }; use crate::helpers::panic_message; use crate::modules::{FeaturevisorModule, ModuleApi, ModuleSubscription}; @@ -434,11 +435,6 @@ impl Featurevisor { } result } - /// Updates sticky evaluations, either merging with or replacing them. - pub fn set_sticky(&self, sticky: StickyFeatures, replace: bool) { - self.set_sticky_features(sticky, replace); - } - /// Updates sticky feature evaluations, either merging with or replacing them. pub fn set_sticky_features(&self, sticky: StickyFeatures, replace: bool) { let (features, emitter) = { @@ -462,18 +458,22 @@ impl Featurevisor { } (features, inner.emitter.clone()) }; - let details = StickySetDetails { + let details = StickyFeaturesSetDetails { features, replaced: replace, }; - let mut diagnostic = Diagnostic::new(LogLevel::Info, "sticky_set", "Sticky features set"); + let mut diagnostic = + Diagnostic::new(LogLevel::Info, "sticky_features_set", "Sticky features set"); diagnostic.details = serde_json::to_value(&details) .ok() .and_then(|value| value.as_object().cloned()) .map(|value| value.into_iter().collect()) .unwrap_or_default(); self.report_diagnostic(diagnostic, None); - emitter.emit(EventName::StickySet, EventDetails::StickySet(details)); + emitter.emit( + EventName::StickyFeaturesSet, + EventDetails::StickyFeaturesSet(details), + ); } /// Updates sticky global variables, either merging with or replacing them. @@ -503,6 +503,17 @@ impl Featurevisor { variables, replaced: replace, }; + let mut diagnostic = Diagnostic::new( + LogLevel::Info, + "sticky_variables_set", + "Sticky variables set", + ); + diagnostic.details = serde_json::to_value(&details) + .ok() + .and_then(|value| value.as_object().cloned()) + .map(|value| value.into_iter().collect()) + .unwrap_or_default(); + self.report_diagnostic(diagnostic, None); emitter.emit( EventName::StickyVariablesSet, EventDetails::StickyVariablesSet(details), @@ -1212,16 +1223,6 @@ impl Featurevisor { }; evaluate_all(&eval_options, &keys) } - /// Deprecated alias for [`Featurevisor::get_feature_evaluations`]. - #[deprecated(note = "use get_feature_evaluations")] - pub fn get_all_evaluations( - &self, - context: Option<&Context>, - feature_keys: &[String], - options: Option<&OverrideOptions>, - ) -> EvaluatedFeatures { - self.get_feature_evaluations(context, feature_keys, options) - } /// Creates a child evaluator with its own context and sticky state. pub fn spawn(&self, context: Context, options: SpawnOptions) -> FeaturevisorChild { FeaturevisorChild::new( @@ -1290,7 +1291,7 @@ impl Featurevisor { evaluation.variable_value } - pub(crate) fn get_all_evaluations_with_sticky( + pub(crate) fn get_feature_evaluations_with_sticky( &self, context: Option<&Context>, feature_keys: &[String], diff --git a/src/lib.rs b/src/lib.rs index 24327d5..cf9713e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,8 +56,8 @@ pub use chrono; pub use diagnostics::{Diagnostic, DiagnosticHandler, LogLevel}; pub use evaluate::{EvaluateOptions, Evaluation, EvaluationReason, EvaluationType}; pub use events::{ - ContextSetDetails, DatafileSetDetails, EventDetails, EventHandler, EventName, StickySetDetails, - StickyVariablesSetDetails, + ContextSetDetails, DatafileSetDetails, EventDetails, EventHandler, EventName, + StickyFeaturesSetDetails, StickyVariablesSetDetails, }; pub use instance::{ create_featurevisor, Featurevisor, FeaturevisorOptions, OverrideOptions, SpawnOptions, diff --git a/tests/conformance.rs b/tests/conformance.rs index 070e33e..1c28d82 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -64,9 +64,9 @@ fn condition_feature(condition: Value) -> featurevisor::DatafileContent { } #[test] -fn fixture_is_version_five_and_every_section_is_present() { +fn fixture_version_and_every_section_are_present() { let fixture: Value = serde_json::from_str(include_str!("../conformance/sdk-v3.json")).unwrap(); - assert_eq!(fixture["version"], 5); + assert_eq!(fixture["version"], 6); for section in [ "bucketing", "regularExpressions", diff --git a/tests/modules.rs b/tests/modules.rs index 1b148ef..154c1a4 100644 --- a/tests/modules.rs +++ b/tests/modules.rs @@ -13,6 +13,56 @@ struct FailingCloseModule; struct GlobalVariableModule; +struct RequiredFeatureModule; + +struct PhaseModule { + name: &'static str, + events: Arc>>, +} + +impl FeaturevisorModule for PhaseModule { + fn before(&self, options: EvaluateOptions) -> EvaluateOptions { + self.events + .lock() + .unwrap() + .push(format!("before:{}", self.name)); + options + } + + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + self.events + .lock() + .unwrap() + .push(format!("beforeEvaluation:{}", self.name)); + options + } + + fn after_evaluation(&self, evaluation: Evaluation, _options: &EvaluateOptions) -> Evaluation { + self.events + .lock() + .unwrap() + .push(format!("afterEvaluation:{}", self.name)); + evaluation + } + + fn after(&self, evaluation: Evaluation, _options: &EvaluateOptions) -> Evaluation { + self.events + .lock() + .unwrap() + .push(format!("after:{}", self.name)); + evaluation + } +} + +impl FeaturevisorModule for RequiredFeatureModule { + fn before_evaluation(&self, mut options: EvaluateOptions) -> EvaluateOptions { + if options.feature_key == "enabled" { + options.feature_key = "disabled".to_string(); + } + options + } +} + impl FeaturevisorModule for GlobalVariableModule { fn before_evaluation(&self, mut options: EvaluateOptions) -> EvaluateOptions { if options.feature_key.is_empty() { @@ -100,6 +150,40 @@ fn module_lifecycle_and_duplicate_names_are_handled() { f.close(); } +#[test] +fn module_phases_follow_the_canonical_order() { + let events = Arc::new(Mutex::new(Vec::new())); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile())), + modules: vec![ + Arc::new(PhaseModule { + name: "first", + events: Arc::clone(&events), + }), + Arc::new(PhaseModule { + name: "second", + events: Arc::clone(&events), + }), + ], + ..Default::default() + }); + + assert!(f.is_enabled("flag", None)); + assert_eq!( + *events.lock().unwrap(), + vec![ + "before:first", + "before:second", + "beforeEvaluation:first", + "beforeEvaluation:second", + "afterEvaluation:first", + "afterEvaluation:second", + "after:first", + "after:second", + ] + ); +} + #[test] fn diagnostics_and_error_events_are_available() { let diagnostics = Arc::new(Mutex::new(Vec::::new())); @@ -160,3 +244,22 @@ fn unified_module_callbacks_apply_to_global_variables() { Some("after") ); } + +#[test] +fn required_feature_evaluations_use_modules() { + let datafile = serde_json::from_value(json!({ + "schemaVersion": "2", "revision": "required", "segments": {}, + "features": { + "enabled": { "bucketBy": "userId", "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] }, + "disabled": { "bucketBy": "userId", "traffic": [] }, + "dependent": { "bucketBy": "userId", "requiredFeatures": ["enabled"], "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] } + } + })).unwrap(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile)), + modules: vec![Arc::new(RequiredFeatureModule)], + ..Default::default() + }); + + assert!(!f.is_enabled("dependent", None)); +}