From 22ee7658344961693a30b5fa430c5dea0d5b932b Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Fri, 28 Aug 2026 22:55:23 +0200 Subject: [PATCH 1/2] feat: global variables --- README.md | 52 ++- conformance/sdk-v3.json | 621 +++++++++++++++++++++++++++- lib/featurevisor.ex | 327 ++++++++++++++- lib/featurevisor/child.ex | 113 ++++- lib/featurevisor/cli.ex | 5 +- lib/featurevisor/cli/benchmark.ex | 15 +- lib/featurevisor/cli/test_runner.ex | 47 ++- lib/featurevisor/evaluation.ex | 11 +- lib/featurevisor/evaluator.ex | 96 +++-- lib/featurevisor/module.ex | 15 +- lib/featurevisor/server.ex | 112 ++++- mix.exs | 2 +- test/cli_test.exs | 7 +- test/conformance_test.exs | 92 ++++- test/featurevisor_test.exs | 2 +- test/modules_and_child_test.exs | 56 ++- 16 files changed, 1475 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index fb12fb9..2218441 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Add `featurevisor` to your dependencies in `mix.exs`: ```elixir def deps do [ - {:featurevisor, "~> 0.1"} + {:featurevisor, "~> 1.0"} ] end ``` @@ -130,11 +130,12 @@ The supervisor owns shutdown and restart. Module close callbacks run during supe ## Evaluation types -Featurevisor evaluates three kinds of values: +Featurevisor evaluates four kinds of values: - a flag answers whether a feature is enabled - a variation returns a variation value -- a variable returns remote configuration for a feature +- a feature variable returns remote configuration owned by a feature +- a global variable returns remote configuration independently of a feature Every evaluation uses the active datafile and the effective context. @@ -221,6 +222,13 @@ title = Featurevisor.get_variable(f, "checkout", "title", context) JSON variables are decoded before they are returned. +Global variables use explicit function names so feature ownership remains clear: + +```elixir +email = Featurevisor.get_global_variable(f, "supportEmail", context) +evaluation = Featurevisor.evaluate_global_variable(f, "supportEmail", context) +``` + ### Type specific getters Use a typed getter when your application wants runtime type validation: @@ -240,15 +248,19 @@ Typed getters return `nil` for a mismatched value and do not coerce strings, boo ## Getting all evaluations ```elixir -evaluations = Featurevisor.get_all_evaluations(f, context) +features = Featurevisor.get_feature_evaluations(f, context) +variables = Featurevisor.get_global_variable_evaluations(f, context) ``` Pass feature keys to evaluate a selected set: ```elixir -evaluations = Featurevisor.get_all_evaluations(f, context, ["checkout", "pricing"]) +features = Featurevisor.get_feature_evaluations(f, context, ["checkout", "pricing"]) +variables = Featurevisor.get_global_variable_evaluations(f, context, ["supportEmail"]) ``` +`get_all_evaluations/4` is a deprecated alias for `get_feature_evaluations/4`. + ## Sticky Sticky values keep selected evaluations stable for the lifetime of an instance or child instance. @@ -258,21 +270,23 @@ Sticky values keep selected evaluations stable for the lifetime of an instance o ```elixir f = Featurevisor.create_featurevisor(%{ datafile: datafile, - sticky: %{ + sticky_features: %{ "checkout" => %{ "enabled" => true, "variation" => "treatment", "variables" => %{"title" => "Welcome back"} } - } + }, + sticky_variables: %{"supportEmail" => "sticky@example.com"} }) ``` ### Updating sticky values ```elixir -Featurevisor.set_sticky(f, sticky) -Featurevisor.set_sticky(f, replacement, true) +Featurevisor.set_sticky_features(f, sticky_features) +Featurevisor.set_sticky_variables(f, sticky_variables) +Featurevisor.set_sticky_features(f, replacement, true) ``` Sticky values are instance state. They are not accepted as public per evaluation options. @@ -283,7 +297,7 @@ Sticky values are instance state. They are not accepted as public per evaluation ### Merging by default -Incoming features and segments are merged into the stored datafile. Incoming entries replace entries with the same key. +Incoming features, global variables, and segments are merged into the stored datafile. Incoming entries replace entries with the same key. ```elixir Featurevisor.set_datafile(f, next_datafile) @@ -346,8 +360,11 @@ Supported events are: - `:datafile_set` - `:context_set` - `:sticky_set` +- `:sticky_variables_set` - `:error` +The `:datafile_set` details include changed `features` and `variables`, including dependants affected by changed required features or segments. + ## Evaluation details Use detailed methods when you need reasons, rule keys, bucket values, or matched definitions: @@ -356,6 +373,7 @@ Use detailed methods when you need reasons, rule keys, bucket values, or matched flag = Featurevisor.evaluate_flag(f, "checkout", context) variation = Featurevisor.evaluate_variation(f, "checkout", context) variable = Featurevisor.evaluate_variable(f, "checkout", "title", context) +global_variable = Featurevisor.evaluate_global_variable(f, "supportEmail", context) ``` Each method returns a `Featurevisor.Evaluation` struct. @@ -370,13 +388,13 @@ module = %Featurevisor.Module{ setup: fn api -> IO.puts("Revision: #{api.get_revision.()}") end, - before: fn options -> + before_evaluation: fn options -> %{options | context: Map.put_new(options.context, "service", "checkout")} end, bucket_value: fn options -> options.bucket_value end, - after: fn evaluation, _options -> + after_evaluation: fn evaluation, _options -> evaluation end, close: fn -> @@ -388,7 +406,7 @@ remove = Featurevisor.add_module(f, module) remove.() ``` -Module callbacks are `setup`, `before`, `bucket_key`, `bucket_value`, `after`, and `close`. Callback option maps use idiomatic snake case keys such as `bucket_key` and `bucket_value`. +Module callbacks are `setup`, `before_evaluation`, `bucket_key`, `bucket_value`, `after_evaluation`, and `close`. The older `before` and `after` callbacks remain feature only compatibility callbacks. Callback option maps use idiomatic snake case keys such as `bucket_key` and `bucket_value`. Named duplicates are rejected with a `duplicate_module` diagnostic. A failed setup is removed, its diagnostic subscriptions are cleared, and its close callback is invoked. @@ -400,14 +418,16 @@ A child has isolated context, sticky state, and local listeners while sharing it child = Featurevisor.spawn( f, %{"accountId" => "account-123"}, - %{sticky: sticky} + %{sticky_features: sticky_features, sticky_variables: sticky_variables} ) Featurevisor.Child.enabled?(child, "checkout", %{"userId" => "user-456"}) Featurevisor.Child.get_variation(child, "checkout") Featurevisor.Child.get_variable(child, "checkout", "title") Featurevisor.Child.get_variable_string(child, "checkout", "title") -Featurevisor.Child.get_all_evaluations(child) +Featurevisor.Child.get_global_variable(child, "supportEmail") +Featurevisor.Child.get_feature_evaluations(child) +Featurevisor.Child.get_global_variable_evaluations(child) Featurevisor.Child.close(child) ``` @@ -467,6 +487,8 @@ Use one or more Targets when required: Benchmark output reports total duration and the minimum, average, and maximum duration of individual evaluations. +Pass `--variable=supportEmail` without `--feature` to benchmark a global variable. + ### Assess distribution ```sh 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/lib/featurevisor.ex b/lib/featurevisor.ex index 97ffdad..05c26d4 100644 --- a/lib/featurevisor.ex +++ b/lib/featurevisor.ex @@ -17,7 +17,8 @@ defmodule Featurevisor do optional(:context) => map(), optional(:log_level) => Diagnostic.level(), optional(:on_diagnostic) => (Diagnostic.t() -> any()), - optional(:sticky) => map(), + optional(:sticky_features) => map(), + optional(:sticky_variables) => map(), optional(:modules) => [Module.t()], optional(:name) => GenServer.name() } @@ -156,15 +157,22 @@ defmodule Featurevisor do @doc "Merges or replaces sticky evaluations." def set_sticky(instance, sticky, replace \\ false) when is_map(sticky) do - if open?(instance), do: set_live_sticky(instance, sticky, replace), else: :ok + set_sticky_features(instance, sticky, replace) end - defp set_live_sticky(instance, sticky, replace) do + @doc "Merges or replaces sticky feature evaluations." + def set_sticky_features(instance, sticky, replace \\ false) when is_map(sticky) do + if open?(instance), do: set_live_sticky_features(instance, sticky, replace), else: :ok + end + + defp set_live_sticky_features(instance, sticky, replace) do {_resolved, keys} = Server.update(instance.pid, fn snapshot -> - previous = snapshot.sticky || %{} + previous = snapshot.sticky_features || %{} value = if replace, do: sticky, else: Map.merge(previous, sticky) - {{value, Enum.uniq(Map.keys(previous) ++ Map.keys(value))}, %{snapshot | sticky: value}} + + {{value, Enum.uniq(Map.keys(previous) ++ Map.keys(value))}, + %{snapshot | sticky_features: value}} end) details = %{features: keys, replaced: replace} @@ -180,6 +188,32 @@ defmodule Featurevisor do :ok end + @doc "Merges or replaces sticky global variable values." + def set_sticky_variables(instance, sticky, replace \\ false) when is_map(sticky) do + if open?(instance) do + {_resolved, keys} = + Server.update(instance.pid, fn snapshot -> + previous = snapshot.sticky_variables || %{} + value = if replace, do: sticky, else: Map.merge(previous, sticky) + + {{value, Enum.uniq(Map.keys(previous) ++ Map.keys(value))}, + %{snapshot | sticky_variables: value}} + end) + + details = %{variables: keys, replaced: replace} + trigger(instance, :sticky_variables_set, details) + + report(instance, %{ + level: :info, + code: "sticky_variables_set", + message: "Sticky variables set", + details: details + }) + end + + :ok + end + @doc "Changes the diagnostic threshold." def set_log_level(instance, level) when level in [:fatal, :error, :warn, :info, :debug] do if open?(instance) do @@ -212,6 +246,10 @@ defmodule Featurevisor do def get_variable_keys(instance, key), do: instance |> get_feature(key) |> then(&Map.keys((&1 && &1["variablesSchema"]) || %{})) + @doc "Returns all global variable keys. Ordering is not guaranteed." + def get_global_variable_keys(instance), + do: Map.keys(snapshot(instance).datafile["variables"] || %{}) + @doc "Returns whether a feature defines variations." def has_variations?(instance, key), do: match?(%{"variations" => [_ | _]}, get_feature(instance, key)) @@ -292,8 +330,218 @@ defmodule Featurevisor do def get_variable_json(instance, feature, variable, context \\ %{}, options \\ %{}), do: get_variable(instance, feature, variable, context, options) + @doc "Evaluates a global variable and returns details." + def evaluate_global_variable(instance, variable_key, context \\ %{}, options \\ %{}) do + snap = snapshot(instance) + options = normalize_options(options) + + evaluation_options = %{ + type: :variable, + variable_key: variable_key, + context: Map.merge(snap.context, context), + default_variable_present: Map.has_key?(options, :default_variable_value), + default_variable_value: Map.get(options, :default_variable_value) + } + + try do + evaluation_options = + Enum.reduce(snap.modules, evaluation_options, fn module, current -> + if module.before_evaluation, do: module.before_evaluation.(current), else: current + end) + + resolved_key = evaluation_options.variable_key + variable = get_in(snap.datafile, ["variables", resolved_key]) + sticky = Map.get(options, :__child_sticky_variables, snap.sticky_variables || %{}) + + evaluation = + cond do + Map.has_key?(sticky, resolved_key) -> + %Featurevisor.Evaluation{ + type: :variable, + variable_key: resolved_key, + variable: variable, + variable_value: sticky[resolved_key], + reason: :sticky + } + + variable && + not required_features_match?( + instance, + variable["requiredFeatures"], + evaluation_options.context, + options + ) -> + value = + if variable["useDefaultWhenDisabled"], + do: variable["defaultValue"], + else: variable["disabledValue"] + + %Featurevisor.Evaluation{ + type: :variable, + variable_key: resolved_key, + variable: variable, + variable_value: value, + reason: :required_features_unmet + } + + variable -> + case matched_global_override( + instance, + snap, + variable["overrides"] || [], + evaluation_options.context, + options + ) do + {override, index} -> + %Featurevisor.Evaluation{ + type: :variable, + variable_key: resolved_key, + variable: variable, + variable_value: override["value"], + variable_override_index: index, + variable_override_key: override["key"], + variable_override_path: override["keyPath"], + reason: :variable_override_rule + } + + nil -> + %Featurevisor.Evaluation{ + type: :variable, + variable_key: resolved_key, + variable: variable, + variable_value: variable["defaultValue"], + reason: :variable_default + } + end + + true -> + %Featurevisor.Evaluation{ + type: :variable, + variable_key: resolved_key, + reason: :variable_not_found + } + end + + value_present = + case evaluation.reason do + :sticky -> + true + + :variable_override_rule -> + true + + :variable_default -> + variable && Map.has_key?(variable, "defaultValue") + + :required_features_unmet -> + variable && + Map.has_key?( + variable, + if(variable["useDefaultWhenDisabled"], do: "defaultValue", else: "disabledValue") + ) + + _ -> + false + end + + evaluation = + if not value_present and evaluation_options.default_variable_present, + do: %{evaluation | variable_value: evaluation_options.default_variable_value}, + else: evaluation + + evaluation = + Enum.reduce(snap.modules, evaluation, fn module, current -> + if module.after_evaluation, + do: module.after_evaluation.(current, evaluation_options), + else: current + end) + + if variable && variable["deprecated"] do + report(instance, %{ + level: :warn, + code: "variable_deprecated", + message: "Variable \"#{resolved_key}\" is deprecated", + details: %{ + variableKey: resolved_key, + evaluation: Featurevisor.Evaluation.to_map(evaluation) + } + }) + end + + report(instance, %{ + level: :debug, + code: Atom.to_string(evaluation.reason), + message: "Global variable evaluated", + details: Featurevisor.Evaluation.to_map(evaluation) + }) + + evaluation + rescue + error -> + evaluation = %Featurevisor.Evaluation{ + type: :variable, + variable_key: variable_key, + reason: :error, + error: error + } + + report(instance, %{ + level: :error, + code: "evaluation_error", + message: "Global variable evaluation failed", + originalError: error, + details: Featurevisor.Evaluation.to_map(evaluation) + }) + + evaluation + end + end + + @doc "Returns a global variable value or nil. JSON variables are decoded." + def get_global_variable(instance, variable_key, context \\ %{}, options \\ %{}) do + evaluation = evaluate_global_variable(instance, variable_key, context, options) + value = evaluation.variable_value + + if get_in(evaluation.variable || %{}, ["type"]) == "json" and is_binary(value) do + case Jason.decode(value) do + {:ok, parsed} -> parsed + {:error, _error} -> nil + end + else + value + end + end + + @doc "Returns a typed boolean global variable." + def get_global_variable_boolean(instance, key, context \\ %{}, options \\ %{}), + do: typed(get_global_variable(instance, key, context, options), :boolean) + + @doc "Returns a typed string global variable." + def get_global_variable_string(instance, key, context \\ %{}, options \\ %{}), + do: typed(get_global_variable(instance, key, context, options), :string) + + @doc "Returns a typed integer global variable." + def get_global_variable_integer(instance, key, context \\ %{}, options \\ %{}), + do: typed(get_global_variable(instance, key, context, options), :integer) + + @doc "Returns a typed numeric global variable." + def get_global_variable_double(instance, key, context \\ %{}, options \\ %{}), + do: typed(get_global_variable(instance, key, context, options), :double) + + @doc "Returns a typed list global variable." + def get_global_variable_array(instance, key, context \\ %{}, options \\ %{}), + do: typed(get_global_variable(instance, key, context, options), :array) + + @doc "Returns a typed map global variable." + def get_global_variable_object(instance, key, context \\ %{}, options \\ %{}), + do: typed(get_global_variable(instance, key, context, options), :object) + + @doc "Returns a decoded JSON global variable." + def get_global_variable_json(instance, key, context \\ %{}, options \\ %{}), + do: get_global_variable(instance, key, context, options) + @doc "Evaluates all or selected features." - def get_all_evaluations(instance, context \\ %{}, feature_keys \\ [], options \\ %{}) do + def get_feature_evaluations(instance, context \\ %{}, feature_keys \\ [], options \\ %{}) do keys = if feature_keys == [], do: get_feature_keys(instance), else: feature_keys Map.new(keys, fn key -> @@ -320,6 +568,22 @@ defmodule Featurevisor do end) end + @doc "Evaluates all or selected global variables." + def get_global_variable_evaluations( + instance, + context \\ %{}, + variable_keys \\ [], + options \\ %{} + ) do + keys = if variable_keys == [], do: get_global_variable_keys(instance), else: variable_keys + Map.new(keys, &{&1, get_global_variable(instance, &1, context, options)}) + end + + @doc "Deprecated alias for get_feature_evaluations/4." + @deprecated "Use get_feature_evaluations/4" + def get_all_evaluations(instance, context \\ %{}, feature_keys \\ [], options \\ %{}), + do: get_feature_evaluations(instance, context, feature_keys, options) + @doc false def segment_matches?(instance, segment_key, context \\ %{}) do snap = snapshot(instance) @@ -425,7 +689,7 @@ defmodule Featurevisor do @doc "Subscribes to an event and returns an idempotent unsubscribe function." def on(instance, event, callback) - when event in [:datafile_set, :context_set, :sticky_set, :error] and + when event in [:datafile_set, :context_set, :sticky_set, :sticky_variables_set, :error] and is_function(callback, 1) do if open?(instance) do subscribe_to_event(instance, event, callback) @@ -491,7 +755,7 @@ defmodule Featurevisor do regex_cache: snap.regex_cache, report: reporter(instance), modules: snap.modules, - sticky: Map.get(options, :__child_sticky, snap.sticky), + sticky: Map.get(options, :__child_sticky_features, snap.sticky_features), default_variation_present: Map.has_key?(options, :default_variation_value), default_variation_value: Map.get(options, :default_variation_value), default_variable_present: Map.has_key?(options, :default_variable_value), @@ -501,6 +765,53 @@ defmodule Featurevisor do Evaluator.evaluate_with_modules(evaluation_options) end + defp required_features_match?(_instance, nil, _context, _options), do: true + + defp required_features_match?(instance, requirements, context, options) do + Enum.all?(requirements, fn requirement -> + {feature, enabled, variation} = + if is_binary(requirement) do + {requirement, true, nil} + else + {requirement["feature"] || requirement["key"], Map.get(requirement, "enabled", true), + requirement["variation"]} + end + + enabled?(instance, feature, context, options) == enabled and + (is_nil(variation) or get_variation(instance, feature, context, options) == variation) + end) + end + + defp matched_global_override(instance, snap, overrides, context, options) do + overrides + |> Enum.with_index() + |> Enum.find_value(fn {override, index} -> + requirements_match = + required_features_match?(instance, override["requiredFeatures"], context, options) + + conditions_match = + not Map.has_key?(override, "conditions") or + Conditions.all_conditions?( + Conditions.parse_conditions(override["conditions"], reporter(instance)), + context, + snap.regex_cache, + reporter(instance) + ) + + segments_match = + not Map.has_key?(override, "segments") or + Conditions.all_segments?( + Conditions.parse_segments(override["segments"]), + context, + snap.datafile["segments"], + snap.regex_cache, + reporter(instance) + ) + + if requirements_match and conditions_match and segments_match, do: {override, index} + end) + end + defp typed(value, :boolean) when is_boolean(value), do: value defp typed(value, :string) when is_binary(value), do: value defp typed(value, :integer) when is_integer(value), do: value diff --git a/lib/featurevisor/child.ex b/lib/featurevisor/child.ex index 30c9280..d0a2f9b 100644 --- a/lib/featurevisor/child.ex +++ b/lib/featurevisor/child.ex @@ -15,7 +15,8 @@ defmodule Featurevisor.Child do Agent.start(fn -> %{ context: stored, - sticky: Map.get(options, :sticky, %{}), + sticky_features: Map.get(options, :sticky_features, %{}), + sticky_variables: Map.get(options, :sticky_variables, %{}), listeners: %{}, unsubs: [], closed: false @@ -49,24 +50,53 @@ defmodule Featurevisor.Child do @doc "Merges or replaces child sticky values." def set_sticky(child, sticky, replace \\ false) do - if Process.alive?(child.agent), do: set_live_sticky(child, sticky, replace), else: :ok + set_sticky_features(child, sticky, replace) end - defp set_live_sticky(child, sticky, replace) do + @doc "Merges or replaces child sticky feature values." + def set_sticky_features(child, sticky, replace \\ false) do + if Process.alive?(child.agent), + do: set_live_sticky_features(child, sticky, replace), + else: :ok + end + + defp set_live_sticky_features(child, sticky, replace) do details = Agent.get_and_update(child.agent, fn state -> - value = if replace, do: sticky, else: Map.merge(state.sticky, sticky) + value = if replace, do: sticky, else: Map.merge(state.sticky_features, sticky) - {%{features: Enum.uniq(Map.keys(state.sticky) ++ Map.keys(value)), replaced: replace}, - %{state | sticky: value}} + {%{ + features: Enum.uniq(Map.keys(state.sticky_features) ++ Map.keys(value)), + replaced: replace + }, %{state | sticky_features: value}} end) trigger(child, :sticky_set, details) :ok end + @doc "Merges or replaces child sticky global variable values." + def set_sticky_variables(child, sticky, replace \\ false) do + if Process.alive?(child.agent) do + details = + Agent.get_and_update(child.agent, fn state -> + value = if replace, do: sticky, else: Map.merge(state.sticky_variables, sticky) + + {%{ + variables: Enum.uniq(Map.keys(state.sticky_variables) ++ Map.keys(value)), + replaced: replace + }, %{state | sticky_variables: value}} + end) + + trigger(child, :sticky_variables_set, details) + end + + :ok + end + @doc "Subscribes to a child or delegated parent event." - def on(child, event, callback) when event in [:context_set, :sticky_set] do + def on(child, event, callback) + when event in [:context_set, :sticky_set, :sticky_variables_set] do if Process.alive?(child.agent) do subscribe_to_local_event(child, event, callback) else @@ -199,8 +229,8 @@ defmodule Featurevisor.Child do do: typed_variable(child, :get_variable_json, key, variable, context, options) @doc "Evaluates all or selected features through the parent instance." - def get_all_evaluations(child, context \\ %{}, feature_keys \\ [], options \\ %{}) do - Featurevisor.get_all_evaluations( + def get_feature_evaluations(child, context \\ %{}, feature_keys \\ [], options \\ %{}) do + Featurevisor.get_feature_evaluations( child.parent, get_context(child, context), feature_keys, @@ -208,6 +238,61 @@ defmodule Featurevisor.Child do ) end + @doc "Deprecated alias for get_feature_evaluations/4." + @deprecated "Use get_feature_evaluations/4" + def get_all_evaluations(child, context \\ %{}, feature_keys \\ [], options \\ %{}), + do: get_feature_evaluations(child, context, feature_keys, options) + + @doc "Evaluates a global variable through the parent instance." + def evaluate_global_variable(child, key, context \\ %{}, options \\ %{}), + do: + Featurevisor.evaluate_global_variable( + child.parent, + key, + get_context(child, context), + child_options(child, options) + ) + + @doc "Returns a global variable value." + def get_global_variable(child, key, context \\ %{}, options \\ %{}), + do: + Featurevisor.get_global_variable( + child.parent, + key, + get_context(child, context), + child_options(child, options) + ) + + for {name, function} <- [ + boolean: :get_global_variable_boolean, + string: :get_global_variable_string, + integer: :get_global_variable_integer, + double: :get_global_variable_double, + array: :get_global_variable_array, + object: :get_global_variable_object, + json: :get_global_variable_json + ] do + @doc "Returns a typed #{name} global variable." + def unquote(function)(child, key, context \\ %{}, options \\ %{}) do + apply(Featurevisor, unquote(function), [ + child.parent, + key, + get_context(child, context), + child_options(child, options) + ]) + end + end + + @doc "Evaluates all or selected global variables through the parent instance." + def get_global_variable_evaluations(child, context \\ %{}, variable_keys \\ [], options \\ %{}) do + Featurevisor.get_global_variable_evaluations( + child.parent, + get_context(child, context), + variable_keys, + child_options(child, options) + ) + end + @doc "Closes child listeners and delegated subscriptions." def close(child) do if Process.alive?(child.agent) do @@ -222,10 +307,14 @@ defmodule Featurevisor.Child do defp child_options(child, options) do options = if is_list(options), do: Map.new(options), else: options - sticky = - if Process.alive?(child.agent), do: Agent.get(child.agent, & &1.sticky), else: %{} + {sticky_features, sticky_variables} = + if Process.alive?(child.agent), + do: Agent.get(child.agent, &{&1.sticky_features, &1.sticky_variables}), + else: {%{}, %{}} - Map.put(options, :__child_sticky, sticky) + options + |> Map.put(:__child_sticky_features, sticky_features) + |> Map.put(:__child_sticky_variables, sticky_variables) end defp typed_variable(child, function, key, variable, context, options) do diff --git a/lib/featurevisor/cli.ex b/lib/featurevisor/cli.ex index e8c9c74..4f215ab 100644 --- a/lib/featurevisor/cli.ex +++ b/lib/featurevisor/cli.ex @@ -57,7 +57,10 @@ defmodule Featurevisor.CLI do defp dispatch(_command, %{help: true}), do: help() defp dispatch("test", options), do: TestRunner.run(options) - defp dispatch("benchmark", %{feature: nil}), do: {:error, "--feature is required"} + + defp dispatch("benchmark", %{feature: nil, variable: nil}), + do: {:error, "--feature or --variable is required"} + defp dispatch("benchmark", %{n: n}) when n <= 0, do: {:error, "--n must be a positive integer"} defp dispatch("benchmark", %{variation: true, variable: variable}) when is_binary(variable), diff --git a/lib/featurevisor/cli/benchmark.ex b/lib/featurevisor/cli/benchmark.ex index a21b982..40c40af 100644 --- a/lib/featurevisor/cli/benchmark.ex +++ b/lib/featurevisor/cli/benchmark.ex @@ -19,8 +19,13 @@ defmodule Featurevisor.CLI.Benchmark do value = evaluator.() durations = for _ <- 1..options.n, do: timed(evaluator) total = Enum.sum(durations) - IO.puts("\nBenchmark Featurevisor feature") - IO.puts("Feature: #{options.feature}") + + IO.puts( + "\nBenchmark Featurevisor #{if options.feature, do: "feature", else: "variable"}" + ) + + if options.feature, do: IO.puts("Feature: #{options.feature}") + if is_nil(options.feature), do: IO.puts("Variable: #{options.variable}") IO.puts("Environment: #{options.environment || false}") if target, do: IO.puts("Target: #{target}") IO.puts("Iterations: #{options.n}") @@ -40,7 +45,11 @@ defmodule Featurevisor.CLI.Benchmark do end defp evaluator(f, %{variable: variable, feature: feature}, context) when is_binary(variable), - do: fn -> Featurevisor.get_variable(f, feature, variable, context) end + do: + if(is_binary(feature), + do: fn -> Featurevisor.get_variable(f, feature, variable, context) end, + else: fn -> Featurevisor.get_global_variable(f, variable, context) end + ) defp evaluator(f, %{variation: true, feature: feature}, context), do: fn -> Featurevisor.get_variation(f, feature, context) end diff --git a/lib/featurevisor/cli/test_runner.ex b/lib/featurevisor/cli/test_runner.ex index d1dda19..fa8e8ec 100644 --- a/lib/featurevisor/cli/test_runner.ex +++ b/lib/featurevisor/cli/test_runner.ex @@ -172,7 +172,8 @@ defmodule Featurevisor.CLI.TestRunner do Featurevisor.create_featurevisor(%{ datafile: datafile, context: assertion["context"] || %{}, - sticky: assertion["sticky"], + sticky_features: assertion["sticky"], + sticky_variables: assertion["stickyVariables"], log_level: log_level(options), modules: [module] }) @@ -209,6 +210,47 @@ defmodule Featurevisor.CLI.TestRunner do errors end + defp run_assertion(%{"variable" => variable}, assertion, datafiles, _segments, options) do + datafile = + datafiles[Project.datafile_key(assertion["environment"], assertion["target"])] || + base_datafile(datafiles, assertion["environment"]) + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + context: assertion["context"] || %{}, + sticky_variables: assertion["stickyVariables"], + log_level: log_level(options) + }) + + evaluation_options = + if Map.has_key?(assertion, "defaultVariableValue"), + do: %{default_variable_value: assertion["defaultVariableValue"]}, + else: %{} + + evaluation = Featurevisor.evaluate_global_variable(f, variable, %{}, evaluation_options) + + errors = + compare_present( + [], + assertion, + "expectedValue", + fn -> evaluation.variable_value end, + variable + ) + + errors = + compare_evaluation( + errors, + assertion["expectedEvaluation"], + wire(evaluation), + "#{variable}: variable" + ) + + Featurevisor.close(f) + errors + end + defp compare_present(errors, assertion, key, actual, feature) do if Map.has_key?(assertion, key) and actual.() != assertion[key], do: @@ -292,7 +334,8 @@ defmodule Featurevisor.CLI.TestRunner do |> Enum.reduce(errors, fn {item, index}, current -> child = Featurevisor.spawn(f, item["context"] || %{}, %{ - sticky: item["sticky"] || assertion["sticky"] || %{} + sticky_features: item["sticky"] || assertion["sticky"] || %{}, + sticky_variables: item["stickyVariables"] || assertion["stickyVariables"] || %{} }) current = diff --git a/lib/featurevisor/evaluation.ex b/lib/featurevisor/evaluation.ex index 394bbe6..9c8bdf0 100644 --- a/lib/featurevisor/evaluation.ex +++ b/lib/featurevisor/evaluation.ex @@ -11,6 +11,7 @@ defmodule Featurevisor.Evaluation do | :variable_not_found | :variable_default | :variable_disabled + | :required_features_unmet | :variable_override_variation | :variable_override_rule | :no_match @@ -33,14 +34,17 @@ defmodule Featurevisor.Evaluation do :traffic, :force_index, :force, - :required, + :required_features, :sticky, :variation, :variation_value, :variable_key, :variable_value, :variable_schema, - :variable_override_index + :variable, + :variable_override_index, + :variable_override_key, + :variable_override_path ] @doc "Converts an evaluation to its camelCase wire representation." @@ -62,5 +66,8 @@ defmodule Featurevisor.Evaluation do defp camelize(:variable_value), do: "variableValue" defp camelize(:variable_schema), do: "variableSchema" defp camelize(:variable_override_index), do: "variableOverrideIndex" + defp camelize(:variable_override_key), do: "variableOverrideKey" + defp camelize(:variable_override_path), do: "variableOverridePath" + defp camelize(:required_features), do: "requiredFeatures" defp camelize(key), do: Atom.to_string(key) end diff --git a/lib/featurevisor/evaluator.ex b/lib/featurevisor/evaluator.ex index fbb7eed..4eb02cb 100644 --- a/lib/featurevisor/evaluator.ex +++ b/lib/featurevisor/evaluator.ex @@ -9,9 +9,19 @@ defmodule Featurevisor.Evaluator do if module.before, do: module.before.(current), else: current end) + options = + Enum.reduce(options.modules, options, fn module, current -> + if module.before_evaluation, do: module.before_evaluation.(current), else: current + end) + evaluation = evaluate(options) evaluation = apply_default(evaluation, options) + evaluation = + Enum.reduce(options.modules, evaluation, fn module, current -> + if module.after_evaluation, do: module.after_evaluation.(current, options), else: current + end) + Enum.reduce(options.modules, evaluation, fn module, current -> if module.after, do: module.after.(current, options), else: current end) @@ -387,23 +397,35 @@ defmodule Featurevisor.Evaluator do end) end - defp required(%{type: :flag} = options, %{"required" => required}) - when is_list(required) and required != [] do - enabled = Enum.all?(required, &required_enabled?(options, &1)) - if enabled, do: :continue, else: required_return(options, required) + defp required(%{type: :flag} = options, feature) do + required = feature["requiredFeatures"] || feature["required"] + + if is_list(required) and required != [] do + enabled = Enum.all?(required, &required_enabled?(options, &1)) + if enabled, do: :continue, else: required_return(options, required) + else + :continue + end end defp required(_, _), do: :continue defp required_enabled?(options, required) do - {key, variation} = - if is_binary(required), do: {required, nil}, else: {required["key"], required["variation"]} + {key, enabled, variation} = + if is_binary(required) do + {required, true, nil} + else + {required["feature"] || required["key"], Map.get(required, "enabled", true), + required["variation"]} + end - flag = evaluate(%{options | type: :flag, feature_key: key}) + flag = evaluate(%{options | type: :flag, feature_key: key, variable_key: nil}) - flag.enabled == true and + flag.enabled == true == enabled and (is_nil(variation) or - variation_value(evaluate(%{options | type: :variation, feature_key: key})) == variation) + variation_value( + evaluate(%{options | type: :variation, feature_key: key, variable_key: nil}) + ) == variation) end defp required_return(options, required) do @@ -411,7 +433,7 @@ defmodule Featurevisor.Evaluator do type: :flag, feature_key: options.feature_key, reason: :required, - required: required, + required_features: required, enabled: false } @@ -577,7 +599,9 @@ defmodule Featurevisor.Evaluator do variable_key: variable_key, variable_schema: schema, variable_value: override["value"], - variable_override_index: index + variable_override_index: index, + variable_override_key: override["key"], + variable_override_path: override["keyPath"] }) match?({:value, _}, traffic_value) -> @@ -596,7 +620,9 @@ defmodule Featurevisor.Evaluator do variable_key: variable_key, variable_schema: schema, variable_value: override["value"], - variable_override_index: index + variable_override_index: index, + variable_override_key: override["key"], + variable_override_path: override["keyPath"] }) match?({:value, _}, variation_value_result) -> @@ -623,28 +649,32 @@ defmodule Featurevisor.Evaluator do overrides |> Enum.with_index() |> Enum.find_value(fn {override, index} -> - matched = - cond do - Map.has_key?(override, "conditions") -> - Conditions.all_conditions?( - Conditions.parse_conditions(override["conditions"], options.report), - options.context, - options.regex_cache, - options.report - ) - - Map.has_key?(override, "segments") -> - Conditions.all_segments?( - Conditions.parse_segments(override["segments"]), - options.context, - options.datafile["segments"], - options.regex_cache, - options.report - ) + requirements_match = + Enum.all?(override["requiredFeatures"] || [], &required_enabled?(options, &1)) + + conditions_match = + not Map.has_key?(override, "conditions") or + Conditions.all_conditions?( + Conditions.parse_conditions(override["conditions"], options.report), + options.context, + options.regex_cache, + options.report + ) + + segments_match = + not Map.has_key?(override, "segments") or + Conditions.all_segments?( + Conditions.parse_segments(override["segments"]), + options.context, + options.datafile["segments"], + options.regex_cache, + options.report + ) - true -> - false - end + matched = + requirements_match and conditions_match and segments_match and + (Map.has_key?(override, "conditions") or Map.has_key?(override, "segments") or + Map.has_key?(override, "requiredFeatures")) if matched, do: {:override, override, index} end) diff --git a/lib/featurevisor/module.ex b/lib/featurevisor/module.ex index 4b529c0..0aad9fb 100644 --- a/lib/featurevisor/module.ex +++ b/lib/featurevisor/module.ex @@ -13,11 +13,24 @@ defmodule Featurevisor.Module do name: String.t() | nil, setup: (api() -> any()) | nil, before: (map() -> map()) | nil, + before_evaluation: (map() -> map()) | nil, bucket_key: (map() -> String.t()) | nil, bucket_value: (map() -> non_neg_integer()) | nil, after: (Evaluation.t(), map() -> Evaluation.t()) | nil, + after_evaluation: (Evaluation.t(), map() -> Evaluation.t()) | nil, close: (-> any()) | nil } - defstruct [:name, :setup, :before, :bucket_key, :bucket_value, :after, :close, :id] + defstruct [ + :name, + :setup, + :before, + :before_evaluation, + :bucket_key, + :bucket_value, + :after, + :after_evaluation, + :close, + :id + ] end diff --git a/lib/featurevisor/server.ex b/lib/featurevisor/server.ex index 01de11d..840480c 100644 --- a/lib/featurevisor/server.ex +++ b/lib/featurevisor/server.ex @@ -8,13 +8,15 @@ defmodule Featurevisor.Server do "schemaVersion" => "2", "revision" => "unknown", "segments" => %{}, - "features" => %{} + "features" => %{}, + "variables" => %{} } @closed_snapshot %{ datafile: @empty_datafile, context: %{}, - sticky: nil, + sticky_features: nil, + sticky_variables: nil, log_level: :fatal, on_diagnostic: nil, modules: [], @@ -52,7 +54,8 @@ defmodule Featurevisor.Server do snapshot = %{ datafile: @empty_datafile, context: Map.get(options, :context, %{}), - sticky: Map.get(options, :sticky), + sticky_features: Map.get(options, :sticky_features), + sticky_variables: Map.get(options, :sticky_variables), log_level: Map.get(options, :log_level, :info), on_diagnostic: Map.get(options, :on_diagnostic), modules: [], @@ -142,7 +145,8 @@ defmodule Featurevisor.Server do def valid_datafile?(datafile) do is_map(datafile) and is_binary(datafile["schemaVersion"]) and is_binary(datafile["revision"]) and is_map(datafile["segments"]) and - is_map(datafile["features"]) + is_map(datafile["features"]) and + (is_nil(datafile["variables"]) or is_map(datafile["variables"])) end def merge_datafile(existing, incoming) do @@ -151,7 +155,8 @@ defmodule Featurevisor.Server do "revision" => incoming["revision"], "featurevisorVersion" => incoming["featurevisorVersion"], "segments" => Map.merge(existing["segments"] || %{}, incoming["segments"] || %{}), - "features" => Map.merge(existing["features"] || %{}, incoming["features"] || %{}) + "features" => Map.merge(existing["features"] || %{}, incoming["features"] || %{}), + "variables" => Map.merge(existing["variables"] || %{}, incoming["variables"] || %{}) } end @@ -162,21 +167,116 @@ defmodule Featurevisor.Server do changed = Enum.filter(previous_keys, fn key -> not Map.has_key?(current["features"], key) or + is_nil(get_in(previous, ["features", key, "hash"])) or + is_nil(get_in(current, ["features", key, "hash"])) or get_in(previous, ["features", key, "hash"]) != get_in(current, ["features", key, "hash"]) end) added = Enum.reject(current_keys, &Map.has_key?(previous["features"], &1)) + previous_variable_keys = Map.keys(previous["variables"] || %{}) + current_variable_keys = Map.keys(current["variables"] || %{}) + + changed_variables = + Enum.filter(previous_variable_keys, fn key -> + not Map.has_key?(current["variables"] || %{}, key) or + is_nil(get_in(previous, ["variables", key, "hash"])) or + is_nil(get_in(current, ["variables", key, "hash"])) or + get_in(previous, ["variables", key, "hash"]) != + get_in(current, ["variables", key, "hash"]) + end) + + added_variables = + Enum.reject(current_variable_keys, &Map.has_key?(previous["variables"] || %{}, &1)) + + changed_segments = + Map.keys(previous["segments"] || %{}) + |> Kernel.++(Map.keys(current["segments"] || %{})) + |> Enum.uniq() + |> Enum.filter(&(get_in(previous, ["segments", &1]) != get_in(current, ["segments", &1]))) + |> MapSet.new() + + {affected_features, affected_variables} = + expand_dependencies( + [previous, current], + MapSet.new(changed ++ added), + MapSet.new(changed_variables ++ added_variables), + changed_segments + ) + %{ revision: current["revision"], previousRevision: previous["revision"], revisionChanged: previous["revision"] != current["revision"], - features: Enum.uniq(changed ++ added), + features: affected_features |> MapSet.to_list() |> Enum.sort(), + variables: affected_variables |> MapSet.to_list() |> Enum.sort(), replaced: replace } end + defp expand_dependencies(datafiles, changed_features, changed_variables, changed_segments) do + next_features = + Enum.reduce(datafiles, changed_features, fn datafile, graph_affected -> + Enum.reduce(datafile["features"] || %{}, graph_affected, fn {key, feature}, affected -> + required = references(feature, ["requiredFeatures", "required"]) + segments = references(feature, ["segments"]) + + if Enum.any?(required, &MapSet.member?(affected, &1)) or + Enum.any?(segments, &MapSet.member?(changed_segments, &1)), + do: MapSet.put(affected, key), + else: affected + end) + end) + + next_variables = + Enum.reduce(datafiles, changed_variables, fn datafile, graph_affected -> + Enum.reduce(datafile["variables"] || %{}, graph_affected, fn {key, variable}, affected -> + required = references(variable, ["requiredFeatures"]) + segments = references(variable, ["segments"]) + + if Enum.any?(required, &MapSet.member?(next_features, &1)) or + Enum.any?(segments, &MapSet.member?(changed_segments, &1)), + do: MapSet.put(affected, key), + else: affected + end) + end) + + if next_features == changed_features and next_variables == changed_variables, + do: {next_features, next_variables}, + else: expand_dependencies(datafiles, next_features, next_variables, changed_segments) + end + + defp references(value, fields), do: collect_references(value, MapSet.new(fields), MapSet.new()) + + defp collect_references(value, fields, result) when is_map(value) do + Enum.reduce(value, result, fn {key, child}, current -> + if MapSet.member?(fields, key), + do: collect_expression(child, current), + else: collect_references(child, fields, current) + end) + end + + defp collect_references(value, fields, result) when is_list(value), + do: Enum.reduce(value, result, &collect_references(&1, fields, &2)) + + defp collect_references(_value, _fields, result), do: result + + defp collect_expression(value, result) when is_binary(value), + do: if(value == "*", do: result, else: MapSet.put(result, value)) + + defp collect_expression(value, result) when is_list(value), + do: Enum.reduce(value, result, &collect_expression/2) + + defp collect_expression(value, result) when is_map(value) do + case value["feature"] || value["key"] do + key when is_binary(key) -> MapSet.put(result, key) + _ -> Enum.reduce(Map.values(value), result, &collect_expression/2) + end + end + + defp collect_expression(_value, result), do: result + def module_id(%FeaturevisorModule{id: nil} = module), do: %{module | id: make_ref()} def module_id(%FeaturevisorModule{} = module), do: module end diff --git a/mix.exs b/mix.exs index 6f8679c..12838aa 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Featurevisor.MixProject do use Mix.Project - @version "0.1.0" + @version "1.0.0" @source_url "https://github.com/featurevisor/featurevisor-elixir" def project do diff --git a/test/cli_test.exs b/test/cli_test.exs index 9f8a3c8..4e1019c 100644 --- a/test/cli_test.exs +++ b/test/cli_test.exs @@ -10,7 +10,8 @@ defmodule Featurevisor.CLITest do end test "validates commands before delegating to Node" do - assert {:error, "--feature is required"} = Featurevisor.CLI.execute(["benchmark"]) + assert {:error, "--feature or --variable is required"} = + Featurevisor.CLI.execute(["benchmark"]) assert {:error, "--n must be a positive integer"} = Featurevisor.CLI.execute(["benchmark", "--feature=foo", "--n=0"]) @@ -22,7 +23,7 @@ defmodule Featurevisor.CLITest do end test "legacy flags remain accepted and ignored" do - assert {:error, "--feature is required"} = + assert {:error, "--feature or --variable is required"} = Featurevisor.CLI.execute([ "benchmark", "--with-scopes", @@ -30,7 +31,7 @@ defmodule Featurevisor.CLITest do "--schemaVersion=1" ]) - assert {:error, "--feature is required"} = + assert {:error, "--feature or --variable is required"} = Featurevisor.CLI.execute(["benchmark", "--schema-version=1"]) end end diff --git a/test/conformance_test.exs b/test/conformance_test.exs index f3ca318..30e751f 100644 --- a/test/conformance_test.exs +++ b/test/conformance_test.exs @@ -5,7 +5,7 @@ defmodule Featurevisor.ConformanceTest do @fixture Path.expand("../conformance/sdk-v3.json", __DIR__) |> File.read!() |> Jason.decode!() test "executes the canonical fixture version and bucket boundaries" do - assert @fixture["version"] == 2 + assert @fixture["version"] == 5 assert is_binary(@fixture["description"]) assert Enum.sort(Map.keys(@fixture)) == @@ -16,6 +16,8 @@ defmodule Featurevisor.ConformanceTest do "regularExpressions", "typedVariables", "datafile", + "globalVariables", + "requiredFeatures", "diagnostics", "numericBucketKeys", "portableConditions", @@ -59,6 +61,92 @@ defmodule Featurevisor.ConformanceTest do end) end + test "evaluates canonical global variables and required features" do + global = @fixture["globalVariables"] + + Enum.each(global["cases"], fn item -> + f = + Featurevisor.create_featurevisor(%{ + datafile: global["datafile"], + sticky_variables: item["stickyVariables"], + log_level: :fatal + }) + + options = + if Map.has_key?(item, "defaultVariableValue"), + do: %{default_variable_value: item["defaultVariableValue"]}, + else: %{} + + evaluation = + Featurevisor.evaluate_global_variable(f, item["key"], item["context"] || %{}, options) + + assert Atom.to_string(evaluation.reason) == item["expectedReason"], item["name"] + + if Map.has_key?(item, "expectedValue"), + do: assert(evaluation.variable_value == item["expectedValue"], item["name"]), + else: assert(is_nil(evaluation.variable_value), item["name"]) + + assert evaluation.variable_override_index == item["expectedOverrideIndex"], item["name"] + assert evaluation.variable_override_key == item["expectedOverrideKey"], item["name"] + assert evaluation.variable_override_path == item["expectedOverridePath"], item["name"] + Featurevisor.close(f) + end) + + required = @fixture["requiredFeatures"] + f = Featurevisor.create_featurevisor(%{datafile: required["datafile"], log_level: :fatal}) + + Enum.each(required["cases"], fn item -> + assert Featurevisor.enabled?(f, item["feature"]) == item["expectedEnabled"], item["name"] + end) + + item = required["featureVariableCase"] + evaluation = Featurevisor.evaluate_variable(f, item["feature"], item["variable"]) + assert evaluation.variable_value == item["expectedValue"] + assert evaluation.variable_override_key == item["expectedOverrideKey"] + Featurevisor.close(f) + end + + test "datafile events include global variables and dependency changes" do + item = @fixture["globalVariables"]["dependencyUpdateCase"] + parent = self() + f = Featurevisor.create_featurevisor(%{datafile: item["initial"], log_level: :fatal}) + Featurevisor.on(f, :datafile_set, &send(parent, {:datafile, &1})) + assert :ok = Featurevisor.set_datafile(f, item["updated"], true) + assert_receive {:datafile, details} + assert Enum.sort(details.features) == Enum.sort(item["expectedChangedFeatures"]) + assert Enum.sort(details.variables) == Enum.sort(item["expectedChangedVariables"]) + Featurevisor.close(f) + end + + test "global null values remain present ahead of caller defaults" do + datafile = %{ + "schemaVersion" => "2", + "revision" => "nulls", + "segments" => %{}, + "features" => %{}, + "variables" => %{"nullable" => %{"type" => "object", "defaultValue" => nil}} + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + sticky_variables: %{"sticky" => nil}, + log_level: :fatal + }) + + options = %{default_variable_value: "caller"} + + assert Featurevisor.evaluate_global_variable(f, "nullable", %{}, options).variable_value == + nil + + assert Featurevisor.evaluate_global_variable(f, "sticky", %{}, options).variable_value == nil + + assert Featurevisor.evaluate_global_variable(f, "missing", %{}, options).variable_value == + "caller" + + Featurevisor.close(f) + end + test "MurmurHash and bucketing match known JavaScript values" do assert Featurevisor.MurmurHash.hash("foo", 1) == 884_891_506 assert Bucketer.bucketed_number("foo") == 20_602 @@ -419,7 +507,7 @@ defmodule Featurevisor.ConformanceTest do f = Featurevisor.create_featurevisor(%{datafile: item["datafile"], log_level: :fatal}) result = - Featurevisor.get_all_evaluations(f, %{}, [], %{ + Featurevisor.get_feature_evaluations(f, %{}, [], %{ default_variation_value: item["defaultVariationValue"] }) diff --git a/test/featurevisor_test.exs b/test/featurevisor_test.exs index 5656d77..bcbb3ed 100644 --- a/test/featurevisor_test.exs +++ b/test/featurevisor_test.exs @@ -92,7 +92,7 @@ defmodule FeaturevisorTest do context = %{"userId" => "1", "country" => "nl"} assert Featurevisor.enabled?(f, "forced", context) assert Featurevisor.get_variable(f, "forced", "colour", context) == "orange" - all = Featurevisor.get_all_evaluations(f, context, ["forced"]) + all = Featurevisor.get_feature_evaluations(f, context, ["forced"]) assert all["forced"].enabled assert all["forced"].variables["colour"] == "orange" end diff --git a/test/modules_and_child_test.exs b/test/modules_and_child_test.exs index 94222e1..0a0f5f8 100644 --- a/test/modules_and_child_test.exs +++ b/test/modules_and_child_test.exs @@ -53,7 +53,9 @@ defmodule Featurevisor.ModulesAndChildTest do }) child = - Featurevisor.spawn(f, %{"country" => "de"}, %{sticky: %{"flag" => %{"enabled" => true}}}) + Featurevisor.spawn(f, %{"country" => "de"}, %{ + sticky_features: %{"flag" => %{"enabled" => true}} + }) Featurevisor.set_context(f, %{"country" => "us", "plan" => "pro", "region" => "eu"}, true) @@ -71,7 +73,7 @@ defmodule Featurevisor.ModulesAndChildTest do "enabled" => true } - assert Featurevisor.Child.get_all_evaluations(child, %{}, ["flag"])["flag"].enabled + assert Featurevisor.Child.get_feature_evaluations(child, %{}, ["flag"])["flag"].enabled Featurevisor.Child.close(child) assert Featurevisor.Child.set_context(child, %{"country" => "fr"}) == :ok assert Featurevisor.Child.set_sticky(child, %{}) == :ok @@ -99,6 +101,56 @@ defmodule Featurevisor.ModulesAndChildTest do Featurevisor.close(f) end + test "unified modules and child sticky state support global variables" do + datafile = %{ + "schemaVersion" => "2", + "revision" => "global", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "message" => %{ + "type" => "string", + "defaultValue" => "default", + "overrides" => [ + %{ + "key" => "nl", + "conditions" => %{"attribute" => "country", "operator" => "equals", "value" => "nl"}, + "value" => "matched" + } + ] + } + } + } + + module = %Module{ + name: "global", + before_evaluation: fn options -> + %{options | context: Map.put(options.context, "country", "nl")} + end, + after_evaluation: fn evaluation, _ -> %{evaluation | variable_value: "after"} end + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + sticky_variables: %{"message" => "parent"}, + modules: [module], + log_level: :fatal + }) + + child = Featurevisor.spawn(f, %{}, %{sticky_variables: %{"message" => "child"}}) + plain = Featurevisor.spawn(f) + assert Featurevisor.get_global_variable(f, "message") == "after" + assert Featurevisor.Child.get_global_variable(child, "message") == "after" + assert Featurevisor.Child.get_global_variable(plain, "message") == "after" + + Featurevisor.remove_module(f, "global") + assert Featurevisor.get_global_variable(f, "message") == "parent" + assert Featurevisor.Child.get_global_variable(child, "message") == "child" + assert Featurevisor.Child.get_global_variable(plain, "message") == "default" + Featurevisor.close(f) + end + test "concurrent module registration reserves names and returned unsubscribers survive close" do parent = self() f = Featurevisor.create_featurevisor(%{log_level: :fatal}) From 66a7a28dd4757eea377f0c2fb77c722152cc9a4b 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 | 6 +- conformance/sdk-v3.json | 51 ++++++++++++++- lib/featurevisor.ex | 26 ++++---- lib/featurevisor/child.ex | 14 +--- lib/featurevisor/evaluator.ex | 39 ++++++++++-- test/conformance_test.exs | 5 +- test/featurevisor_test.exs | 2 +- test/modules_and_child_test.exs | 109 +++++++++++++++++++++++++++++++- 8 files changed, 213 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 2218441..13ccc7d 100644 --- a/README.md +++ b/README.md @@ -259,8 +259,6 @@ features = Featurevisor.get_feature_evaluations(f, context, ["checkout", "pricin variables = Featurevisor.get_global_variable_evaluations(f, context, ["supportEmail"]) ``` -`get_all_evaluations/4` is a deprecated alias for `get_feature_evaluations/4`. - ## Sticky Sticky values keep selected evaluations stable for the lifetime of an instance or child instance. @@ -359,7 +357,7 @@ Supported events are: - `:datafile_set` - `:context_set` -- `:sticky_set` +- `:sticky_features_set` - `:sticky_variables_set` - `:error` @@ -406,7 +404,7 @@ remove = Featurevisor.add_module(f, module) remove.() ``` -Module callbacks are `setup`, `before_evaluation`, `bucket_key`, `bucket_value`, `after_evaluation`, and `close`. The older `before` and `after` callbacks remain feature only compatibility callbacks. Callback option maps use idiomatic snake case keys such as `bucket_key` and `bucket_value`. +Module callbacks are `setup`, `before`, `before_evaluation`, `bucket_key`, `bucket_value`, `after_evaluation`, `after`, and `close`. 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. Callback option maps use idiomatic snake case keys such as `bucket_key` and `bucket_value`. Named duplicates are rejected with a `duplicate_module` diagnostic. A failed setup is removed, its diagnostic subscriptions are cleared, and its close callback is invoked. 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/lib/featurevisor.ex b/lib/featurevisor.ex index 05c26d4..26cf363 100644 --- a/lib/featurevisor.ex +++ b/lib/featurevisor.ex @@ -155,11 +155,6 @@ defmodule Featurevisor do @doc "Returns stored context merged with optional evaluation context." def get_context(instance, context \\ %{}), do: Map.merge(snapshot(instance).context, context) - @doc "Merges or replaces sticky evaluations." - def set_sticky(instance, sticky, replace \\ false) when is_map(sticky) do - set_sticky_features(instance, sticky, replace) - end - @doc "Merges or replaces sticky feature evaluations." def set_sticky_features(instance, sticky, replace \\ false) when is_map(sticky) do if open?(instance), do: set_live_sticky_features(instance, sticky, replace), else: :ok @@ -176,15 +171,16 @@ defmodule Featurevisor do end) details = %{features: keys, replaced: replace} - trigger(instance, :sticky_set, details) report(instance, %{ level: :info, - code: "sticky_set", + code: "sticky_features_set", message: "Sticky features set", details: details }) + trigger(instance, :sticky_features_set, details) + :ok end @@ -201,7 +197,6 @@ defmodule Featurevisor do end) details = %{variables: keys, replaced: replace} - trigger(instance, :sticky_variables_set, details) report(instance, %{ level: :info, @@ -209,6 +204,8 @@ defmodule Featurevisor do message: "Sticky variables set", details: details }) + + trigger(instance, :sticky_variables_set, details) end :ok @@ -579,11 +576,6 @@ defmodule Featurevisor do Map.new(keys, &{&1, get_global_variable(instance, &1, context, options)}) end - @doc "Deprecated alias for get_feature_evaluations/4." - @deprecated "Use get_feature_evaluations/4" - def get_all_evaluations(instance, context \\ %{}, feature_keys \\ [], options \\ %{}), - do: get_feature_evaluations(instance, context, feature_keys, options) - @doc false def segment_matches?(instance, segment_key, context \\ %{}) do snap = snapshot(instance) @@ -689,7 +681,13 @@ defmodule Featurevisor do @doc "Subscribes to an event and returns an idempotent unsubscribe function." def on(instance, event, callback) - when event in [:datafile_set, :context_set, :sticky_set, :sticky_variables_set, :error] and + when event in [ + :datafile_set, + :context_set, + :sticky_features_set, + :sticky_variables_set, + :error + ] and is_function(callback, 1) do if open?(instance) do subscribe_to_event(instance, event, callback) diff --git a/lib/featurevisor/child.ex b/lib/featurevisor/child.ex index d0a2f9b..cfb3c7d 100644 --- a/lib/featurevisor/child.ex +++ b/lib/featurevisor/child.ex @@ -48,11 +48,6 @@ defmodule Featurevisor.Child do Featurevisor.get_context(child.parent) |> Map.merge(stored) |> Map.merge(context) end - @doc "Merges or replaces child sticky values." - def set_sticky(child, sticky, replace \\ false) do - set_sticky_features(child, sticky, replace) - end - @doc "Merges or replaces child sticky feature values." def set_sticky_features(child, sticky, replace \\ false) do if Process.alive?(child.agent), @@ -71,7 +66,7 @@ defmodule Featurevisor.Child do }, %{state | sticky_features: value}} end) - trigger(child, :sticky_set, details) + trigger(child, :sticky_features_set, details) :ok end @@ -96,7 +91,7 @@ defmodule Featurevisor.Child do @doc "Subscribes to a child or delegated parent event." def on(child, event, callback) - when event in [:context_set, :sticky_set, :sticky_variables_set] do + when event in [:context_set, :sticky_features_set, :sticky_variables_set] do if Process.alive?(child.agent) do subscribe_to_local_event(child, event, callback) else @@ -238,11 +233,6 @@ defmodule Featurevisor.Child do ) end - @doc "Deprecated alias for get_feature_evaluations/4." - @deprecated "Use get_feature_evaluations/4" - def get_all_evaluations(child, context \\ %{}, feature_keys \\ [], options \\ %{}), - do: get_feature_evaluations(child, context, feature_keys, options) - @doc "Evaluates a global variable through the parent instance." def evaluate_global_variable(child, key, context \\ %{}, options \\ %{}), do: diff --git a/lib/featurevisor/evaluator.ex b/lib/featurevisor/evaluator.ex index 4eb02cb..9c35d49 100644 --- a/lib/featurevisor/evaluator.ex +++ b/lib/featurevisor/evaluator.ex @@ -53,10 +53,13 @@ defmodule Featurevisor.Evaluator do do: %{evaluation | variation_value: options.default_variation_value} defp apply_default( - %Evaluation{type: :variable, variable_value: nil} = evaluation, + %Evaluation{type: :variable} = evaluation, %{default_variable_present: true} = options - ), - do: %{evaluation | variable_value: options.default_variable_value} + ) do + if variable_value_present?(evaluation), + do: evaluation, + else: %{evaluation | variable_value: options.default_variable_value} + end defp apply_default(evaluation, _), do: evaluation @@ -419,12 +422,17 @@ defmodule Featurevisor.Evaluator do required["variation"]} end - flag = evaluate(%{options | type: :flag, feature_key: key, variable_key: nil}) + flag = evaluate_with_modules(%{options | type: :flag, feature_key: key, variable_key: nil}) flag.enabled == true == enabled and (is_nil(variation) or variation_value( - evaluate(%{options | type: :variation, feature_key: key, variable_key: nil}) + evaluate_with_modules(%{ + options + | type: :variation, + feature_key: key, + variable_key: nil + }) ) == variation) end @@ -699,6 +707,27 @@ defmodule Featurevisor.Evaluator do struct(Evaluation, Map.merge(base, extras)) end + defp variable_value_present?(%Evaluation{variable_value: value}) when not is_nil(value), + do: true + + defp variable_value_present?(%Evaluation{reason: reason}) + when reason in [ + :sticky, + :forced, + :rule, + :allocated, + :variable_disabled, + :variable_override_rule, + :variable_override_variation + ], + do: true + + defp variable_value_present?(%Evaluation{reason: :variable_default, variable_schema: schema}) + when is_map(schema), + do: Map.has_key?(schema, "defaultValue") + + defp variable_value_present?(_), do: false + defp variation_value(%Evaluation{variation_value: value}) when not is_nil(value), do: value defp variation_value(%Evaluation{variation: variation}) when is_map(variation), diff --git a/test/conformance_test.exs b/test/conformance_test.exs index 30e751f..cf0d803 100644 --- a/test/conformance_test.exs +++ b/test/conformance_test.exs @@ -5,7 +5,7 @@ defmodule Featurevisor.ConformanceTest do @fixture Path.expand("../conformance/sdk-v3.json", __DIR__) |> File.read!() |> Jason.decode!() test "executes the canonical fixture version and bucket boundaries" do - assert @fixture["version"] == 5 + assert @fixture["version"] == 6 assert is_binary(@fixture["description"]) assert Enum.sort(Map.keys(@fixture)) == @@ -24,6 +24,9 @@ defmodule Featurevisor.ConformanceTest do "conditionCases", "childInstances", "defaults", + "modulePipeline", + "lifecycle", + "openFeature", "diagnosticCase", "nativeContexts" ]) diff --git a/test/featurevisor_test.exs b/test/featurevisor_test.exs index bcbb3ed..e6ed775 100644 --- a/test/featurevisor_test.exs +++ b/test/featurevisor_test.exs @@ -145,7 +145,7 @@ defmodule FeaturevisorTest do assert Featurevisor.set_datafile(f, Featurevisor.TestFixtures.datafile(), true) == :ok assert Featurevisor.set_context(f, %{"country" => "nl"}) == :ok - assert Featurevisor.set_sticky(f, %{"flag" => %{"enabled" => true}}) == :ok + assert Featurevisor.set_sticky_features(f, %{"flag" => %{"enabled" => true}}) == :ok assert Featurevisor.set_log_level(f, :debug) == :ok assert Featurevisor.remove_module(f, "missing") == :ok assert Featurevisor.add_module(f, %Module{name: "late"}) == nil diff --git a/test/modules_and_child_test.exs b/test/modules_and_child_test.exs index 0a0f5f8..f05dd41 100644 --- a/test/modules_and_child_test.exs +++ b/test/modules_and_child_test.exs @@ -44,6 +44,113 @@ defmodule Featurevisor.ModulesAndChildTest do Featurevisor.close(f) end + test "module phases follow the canonical order" do + parent = self() + + modules = + Enum.map(["first", "second"], fn name -> + %Module{ + name: name, + before: fn options -> + send(parent, "before:#{name}") + options + end, + before_evaluation: fn options -> + send(parent, "beforeEvaluation:#{name}") + options + end, + after_evaluation: fn evaluation, _ -> + send(parent, "afterEvaluation:#{name}") + evaluation + end, + after: fn evaluation, _ -> + send(parent, "after:#{name}") + evaluation + end + } + end) + + f = + Featurevisor.create_featurevisor(%{ + datafile: Featurevisor.TestFixtures.datafile(), + modules: modules, + log_level: :fatal + }) + + Featurevisor.enabled?(f, "flag", %{"userId" => "1"}) + + assert_receive "before:first" + assert_receive "before:second" + assert_receive "beforeEvaluation:first" + assert_receive "beforeEvaluation:second" + assert_receive "afterEvaluation:first" + assert_receive "afterEvaluation:second" + assert_receive "after:first" + assert_receive "after:second" + end + + test "required feature evaluations use modules" do + datafile = %{ + "schemaVersion" => "2", + "revision" => "required", + "segments" => %{}, + "features" => %{ + "enabled" => %{ + "bucketBy" => "userId", + "traffic" => [%{"key" => "all", "segments" => "*", "percentage" => 100_000}] + }, + "disabled" => %{"bucketBy" => "userId", "traffic" => []}, + "dependent" => %{ + "bucketBy" => "userId", + "requiredFeatures" => ["enabled"], + "traffic" => [%{"key" => "all", "segments" => "*", "percentage" => 100_000}] + } + } + } + + module = %Module{ + name: "redirect", + before_evaluation: fn options -> + if options.feature_key == "enabled", + do: %{options | feature_key: "disabled"}, + else: options + end + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + modules: [module], + log_level: :fatal + }) + + refute Featurevisor.enabled?(f, "dependent") + end + + test "explicit null feature variable beats the caller default" do + datafile = %{ + "schemaVersion" => "2", + "revision" => "null", + "segments" => %{}, + "features" => %{ + "feature" => %{ + "bucketBy" => "userId", + "variablesSchema" => %{"value" => %{"type" => "json", "defaultValue" => nil}}, + "traffic" => [%{"key" => "all", "segments" => "*", "percentage" => 100_000}] + } + } + } + + f = Featurevisor.create_featurevisor(%{datafile: datafile, log_level: :fatal}) + + evaluation = + Featurevisor.evaluate_variable(f, "feature", "value", %{}, %{ + default_variable_value: %{"fallback" => true} + }) + + assert evaluation.variable_value == nil + end + test "child snapshots existing parent keys, inherits new keys, and owns sticky" do f = Featurevisor.create_featurevisor(%{ @@ -76,7 +183,7 @@ defmodule Featurevisor.ModulesAndChildTest do assert Featurevisor.Child.get_feature_evaluations(child, %{}, ["flag"])["flag"].enabled Featurevisor.Child.close(child) assert Featurevisor.Child.set_context(child, %{"country" => "fr"}) == :ok - assert Featurevisor.Child.set_sticky(child, %{}) == :ok + assert Featurevisor.Child.set_sticky_features(child, %{}) == :ok refute Featurevisor.Child.enabled?(child, "missing") Featurevisor.Child.close(child) Featurevisor.close(f)