diff --git a/docs/other_resources/in-process-providers/evaluators/fractional_evaluation.md b/docs/other_resources/in-process-providers/evaluators/fractional_evaluation.md new file mode 100644 index 000000000..20cc1ce93 --- /dev/null +++ b/docs/other_resources/in-process-providers/evaluators/fractional_evaluation.md @@ -0,0 +1,192 @@ +# Fractional Evaluation + +This evaluator allows to split the returned variants of a feature flag into different buckets, +where each bucket can be assigned a percentage, representing how many requests will resolve to the corresponding +variant. The sum of all weights must be 100, and the distribution must be performed by using the value of a referenced +from the evaluation context to hash that value and map it to a value between [0, 100]. It is important to note +that evaluations MUST be sticky, meaning that flag resolution requests containing the same value for the +referenced property in their context MUST always resolve to the same variant. For calculating the hash value of the +referenced evaluation context property, the [MurmurHash3](https://github.com/aappleby/smhasher/blob/master/src/MurmurHash3.cpp) +hash function should be used. This is to ensure that flag resolution requests yield the same result, +regardless of which implementation of the in-process flagd provider is being used. + +array containing at least two items, with the first item being an optional [json logic variable declaration](https://jsonlogic.com/operations.html#var) +specifying the target property to base the distribution of values on. If not supplied, a concatination of the +`flagKey` and `targetingKey` are used: `{"cat": [{"var":"$flagd.flag_key"}, {"var":"user.email"}]}`. +The remaining items are `arrays`, each with two values, with the first being `string` item representing the name of the variant, and the +second being a `float` item representing the percentage for that variant. The percentages of all items must add up to +100.0, otherwise unexpected behavior can occur during the evaluation. The `data` object can be an arbitrary +JSON object. Below is an example for a targetingRule containing a `fractionalEvaluation`: + +```json +{ + "flags": { + "headerColor": { + "variants": { + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00" + }, + "defaultVariant": "red", + "state": "ENABLED", + "targeting": { + "fractionalEvaluation": [ + {"var":"email"}, + [ + "red", + 50 + ], + [ + "blue", + 20 + ], + [ + "green", + 30 + ] + ] + } + } + } +} +``` + +Please note that the implementation of this evaluator can assume that instead of `{"var": "email"}`, it will receive +the resolved value of that referenced property, as resolving the value will be taken care of by JsonLogic before +applying the evaluator. + +The following flow chart depicts the logic of this evaluator: + +```mermaid +flowchart TD +A[Parse targetingRule] --> B{Is an array containing at least two items?}; +B -- Yes --> C{Is targetingRule at index 0 a string?}; +B -- No --> D[Return nil]; +C -- Yes --> E[targetPropertyValue := targetingRule at index 0]; +C -- No --> D; +E -- Yes --> F[Iterate through the remaining elements of the targetingRule array and parse the variants and their percentages]; +F --> G{Parsing successful?}; +G -- No --> D; +G -- Yes --> H{Does percentage of variants add up to 100?}; +H -- No --> D; +H -- Yes --> I[hash := murmur3Hash of targetPropertyValue divided by Int64.MaxValue] +I --> L[Iterate through the variant and increment the threshold by the percentage of each variant. Return the first variant where the bucket is smaller than the threshold.] +``` + +As a reference, below is a simplified version of the actual implementation of this evaluator in Go. + +```go + +type fractionalEvaluationDistribution struct { + variant string + percentage int +} + +/* + values: contains the targeting rule object; e.g.: + [ + {"var":"email"}, + [ + "red", + 50 + ], + [ + "blue", + 20 + ], + [ + "green", + 30 + ] + ] + + data: contains the evaluation context; e.g.: + { + "email": "test@faas.com" + } +*/ +func FractionalEvaluation(values, data interface{}) interface{} { + // 1. Check if the values object contains at least two elements: + valuesArray, ok := values.([]interface{}) + if !ok { + log.Error("fractional evaluation data is not an array") + return nil + } + if len(valuesArray) < 2 { + log.Error("fractional evaluation data has length under 2") + return nil + } + + // 2. Get the target property value used for bucketing the values + valueToDistribute, ok := valuesArray[0].(string) + if !ok { + log.Error("first element of fractional evaluation data isn't of type string") + return nil + } + + // 3. Parse the fractionalEvaluation values distribution + sumOfPercentages := 0 + var feDistributions []fractionalEvaluationDistribution + + // start at index 1, as the first item of the values array is the target property + for i := 1; i < len(valuesArray); i++ { + distributionArray, ok := values[i].([]interface{}) + if !ok { + log.Error("distribution elements aren't of type []interface{}") + return nil + } + + if len(distributionArray) != 2 { + log.Error("distribution element isn't length 2") + return nil + } + + variant, ok := distributionArray[0].(string) + if !ok { + log.Error("first element of distribution element isn't a string") + return nil + } + + percentage, ok := distributionArray[1].(float64) + if !ok { + log.Error("second element of distribution element isn't float") + return nil + } + + sumOfPercentages += int(percentage) + + feDistributions = append(feDistributions, fractionalEvaluationDistribution{ + variant: variant, + percentage: int(percentage), + }) + } + + // check if the sum of percentages adds up to 100, otherwise log an error + if sumOfPercentages != 100 { + log.Error("percentages must sum to 100, got: %d", sumOfPercentages) + return nil + } + + // 4. Calculate the hash of the target property and map it to a number between [0, 99] + hashValue := murmur3.HashString(value) + + // divide the hash value by the largest possible value, integer 2^64 + hashRatio := float64(hashValue) / math.Pow(2, 64) + + // integer in range [0, 99] + bucket := int(hashRatio * 100) + + // 5. Iterate through the variant and increment the threshold by the percentage of each variant. + // return the first variant where the bucket is smaller than the threshold. + rangeEnd := 0 + for _, dist := range feDistribution { + rangeEnd += dist.percentage + if bucket < rangeEnd { + // return the matching variant + return dist.variant + } + } + + return "" +} +``` diff --git a/docs/other_resources/in-process-providers/evaluators/semver_evaluation.md b/docs/other_resources/in-process-providers/evaluators/semver_evaluation.md new file mode 100644 index 000000000..aa86b947c --- /dev/null +++ b/docs/other_resources/in-process-providers/evaluators/semver_evaluation.md @@ -0,0 +1,45 @@ +# Semantic Versioning Evaluation + +This evaluator checks if the given property within the evaluation context matches a semantic versioning condition. +It returns 'true', if the value of the given property meets the condition, 'false' if not. + +The implementation of this evaluator should accept the object containing the `sem_ver` evaluator +configuration, and a `data` object containing the evaluation context. +The 'sem_ver' evaluation rule contains exactly three items: + +1. Target property value: the resolved value of the target property referenced in the targeting rule +2. Operator: One of the following: `=`, `!=`, `>`, `<`, `>=`, `<=`, `~` (match minor version), `^` (match major version) +3. Target value: this needs to resolve to a semantic versioning string. If this condition is not met, the evaluator should +log an appropriate error message and return `nil` + +The `sem_ver` evaluation returns a boolean, indicating whether the condition has been met. + +```js +{ + "if": [ + { + "sem_ver": [{"var": "version"}, ">=", "1.0.0"] + }, + "red", null + ] +} +``` + +Please note that the implementation of this evaluator can assume that instead of `{"var": "version"}`, it will receive +the resolved value of that referenced property, as resolving the value will be taken care of by JsonLogic before +applying the evaluator. + +The following flow chart depicts the logic of this evaluator: + +```mermaid +flowchart TD +A[Parse targetingRule] --> B{Is an array containing exactly three items?}; +B -- Yes --> C{Is targetingRule at index 0 a semantic version string?}; +B -- No --> D[Return nil]; +C -- Yes --> E{Is targetingRule at index 1 a supported operator?}; +C -- No --> D; +E -- Yes --> F{Is targetingRule at index 2 a semantic version string?}; +E -- No --> D; +F -- No --> D; +F --> G[Compare the two versions using the operator and return a boolean value indicating if they match]; +``` diff --git a/docs/other_resources/in-process-providers/evaluators/string_comparison_evaluation.md b/docs/other_resources/in-process-providers/evaluators/string_comparison_evaluation.md new file mode 100644 index 000000000..bfa5b9fc0 --- /dev/null +++ b/docs/other_resources/in-process-providers/evaluators/string_comparison_evaluation.md @@ -0,0 +1,40 @@ +# StartsWith/EndsWith evaluation + +This evaluator selects a variant based on whether the specified property within the evaluation context +starts/ends with a certain string. + +The implementation of this evaluator should accept the object containing the `starts_with` or `ends_with` evaluator +configuration, and a `data` object containing the evaluation context. +The `starts_with`/`ends_with` evaluation rule contains exactly two items: + +1. The resolved string value from the evaluation context +2. The target string value + +The `starts_with`/`ends_with` evaluation returns a boolean, indicating whether the condition has been met. + +```js +// starts_with property name used in a targeting rule +"starts_with": [ + // Evaluation context property the be evaluated + {"var": "email"}, + // prefix that has to be present in the value of the referenced property + "user@faas" +] +``` + +Please note that the implementation of this evaluator can assume that instead of `{"var": "email"}`, it will receive +the resolved value of that referenced property, as resolving the value will be taken care of by JsonLogic before +applying the evaluator. + +The following flow chart depicts the logic of this evaluator: + +```mermaid +flowchart TD +A[Parse targetingRule] --> B{Is an array containing exactly two items?}; +B -- Yes --> C{Is targetingRule at index 0 a string?}; +B -- No --> D[Return nil]; +C -- Yes --> E{Is targetingRule at index 1 a string?}; +C -- No --> D; +E -- No --> D; +E --> F[Return a boolean value indicating if the first string starts/ends with the second string]; +``` diff --git a/docs/other_resources/in-process-providers/specification.md b/docs/other_resources/in-process-providers/specification.md new file mode 100755 index 000000000..f9f62a0b7 --- /dev/null +++ b/docs/other_resources/in-process-providers/specification.md @@ -0,0 +1,247 @@ +# Creating an in-process flagd provider + +By default, **flagd** is a remote service that is accessed via **grpc** by a client application to retrieve feature flags. +Depending on the environment, flagd therefore is usually deployed as a standalone service, e.g. as a Kubernetes Deployment, +or injected as a sidecar container into the pod running the client application, +as it is done in the [OpenFeature Operator](https://github.com/open-feature/open-feature-operator). +An in-process flagd provider, on the other hand, is designed to be embedded into the application and therefore +no communication outside the process of the application for feature flag evaluation is needed. This can be desired by some architectures, +especially if flag retrievals should not take longer than a certain amount of time. + +The in-process flagd provider is responsible for creating an abstraction between the [JsonLogic](https://jsonlogic.com) based evaluation of flag configurations following the [flag configuration scheme](https://github.com/open-feature/schemas/blob/main/json/flagd-definitions.json) used by `flagd` and the OpenFeature SDK (for the [chosen technology](https://openfeature.dev/docs/reference/technologies/)). + +Prerequisites: + +- Understanding of [general provider concepts](https://openfeature.dev/docs/reference/concepts/provider/) +- Proficiency in the chosen programming language (check the language isn't already covered by the [existing providers](../usage/flagd_providers.md)) + +The Flag Configuration containing the feature flags and JsonLogic based targeting rules shall be retrieved by the +in-process flagd provider via a gRPC client connection to a sync server, such as [flagd-proxy](https://github.com/open-feature/flagd/tree/main/flagd-proxy). + +## Sync source + +An implementation of an in-process flagd-provider must accept the following environment variables which determine the sync source: + +- `FLAGD_SOURCE_URI`: The URI identifying the sync source. Depending on the sync provider type, this can be the URI of a gRPC service providing the `sync` API required by the in-process flagd provider, or the name of a [core.openfeature.dev/v1alpha2.FeatureFlagConfiguration](https://github.com/open-feature/open-feature-operator/blob/main/docs/crds.md#featureflagconfiguration-1) Custom Resource containing the flag configuration. +- `FLAGD_SOURCE_PROVIDER_TYPE`: The type of the provider. E.g. `grpc` or `kubernetes`. +- `FLAGD_SOURCE_SELECTOR`: Optional selector for the feature flag configuration of interest. This is used as a `selector` for the flagd-proxie's sync API to identify a flag configuration within a collection of feature flag configurations. + +An implementation of an in-process flagd provider should provide a source for retrieving the flag configuration, namely a gRPC source. +Other sources may be desired eventually, so separation of concerns should be maintained between the abstractions evaluating flags and those retrieving confirmation. + +## gRPC sources + +gRPC sync sources are identified by the `provider` field set to `grpc`. +When such a sync source is specified, the in-process flagd provider should connect to the gRPC service located at the `uri` of the sync source, and use its `sync` API ([see here](https://github.com/open-feature/schemas)) to retrieve the feature flag configurations. +If the `selector` field of the sync source is set, that selector should be passed through to the `Sync` and `FetchAllFlags` requests sent to the gRPC server. + +### Protobuf + +Protobuf schemas define the contract between a client (flagd or the in-process provider implementation) and server (`flagd-proxy`). +`flagd-proxy`'s schemas are defined [here](https://github.com/open-feature/schemas/tree/main/protobuf/sync/v1). + +#### Code generation for gRPC sync + +Leverage the [buf CLI](https://docs.buf.build/installation) or protoc to generate a `flagd-proxy` client in the chosen technology: + +Add the [open-feature schema repository](https://github.com/open-feature/schemas) as a submodule + +```shell +git submodule add --force https://github.com/open-feature/schemas.git +``` + +Create a `buf.gen.{chosen language}.yaml` for the chosen language in `schemas/protobuf` (if it doesn't already exist) using one of the other files as a template (find a plugin for the chosen language [here](https://buf.build/protocolbuffers/plugins)) and create a pull request with this file. + +Generate the code (this step ought to be automated in the build process for the chosen technology so that the generated code is never committed) + +```shell +cd schemas/protobuf +buf generate --template buf.gen.{chosen language}.yaml +``` + +As an alternative to buf, use the .proto file directly along with whatever protoc-related tools or plugins avaialble for your language. + +Move the generated code (following convention for the chosen language) and add its location to .gitignore + +Note that for the in-process provider only the `sync` package will be relevant, as it does not communicate with `flagd`, but only with compliant gRPC services such as `flagd-proxy`. + +## JsonLogic evaluation + +An in-process flagd provider should provide the feature set offered by [JsonLogic](https://jsonlogic.com) to evaluate flag resolution requests for a given context. +If available, the JsonLogic library for the chosen technology should be used. + +### Custom JsonLogic evaluators + +In addition to the built-in evaluators provided by JsonLogic, the following custom targeting rules should be implemented by the provider: + +- [Fractional evaluation](../../configuration/fractional_evaluation.md): +This evaluator allows to split the returned variants of a feature flag into different buckets, where each bucket +can be assigned a percentage, representing how many requests will resolve to the corresponding variant. +The sum of all weights must be 100, and the distribution must be performed by using the value of a referenced +from the evaluation context to hash that value and map it to a value between [0, 100]. +It is important to note that evaluations MUST be sticky, meaning that flag resolution requests containing the +same value for the referenced property in their context MUST always resolve to the same variant. +For calculating the hash value of the referenced evaluation context property, +the [MurmurHash3](https://github.com/aappleby/smhasher/blob/master/src/MurmurHash3.cpp) hash function should be used. +This is to ensure that flag resolution requests yield the same result, regardless of which implementation of +the in-process flagd provider is being used. For more specific implementation guidelines, please refer to +[this document](./evaluators/fractional_evaluation.md). +- [SemVer evaluation](../../configuration/sem_ver_evaluation.md): +This evaluator checks if the given property within the evaluation context matches a semantic versioning condition. +It returns 'true', if the value of the given property meets the condition, 'false' if not. +For more specific implementation guidelines, please refer to [this document](./evaluators/semver_evaluation.md). +- [StartsWith/EndsWith evaluation](../../configuration/string_comparison_evaluation.md): +This evaluator selects a variant based on whether the specified property within the evaluation context +starts/ends with a certain string. +For more specific implementation guidelines, please refer to [this document](./evaluators/string_comparison_evaluation.md). + +## Provider construction + +(**using Go as an example**) + +Create a provider struct/class/type (whichever is relevant to the chosen language) with an exported (public) constructor allowing configuration (e.g. `flagd` host). +Give the provider an un-exported (private) client field, set this field as the client generated by the previous step. + +Create methods for the provider to satisfy the chosen language SDK's provider interface. +These methods ought to wrap the built client's methods. + +```go +type Provider struct { + evaluator IJsonEvaluator +} + +type ProviderOption func(*Provider) + +func NewProvider(options ...ProviderOption) *Provider { + provider := &Provider{} + for _, opt := range opts { + opt(provider) + } + + // create a store that is responsible for retrieving the flag configurations + // from the sources that are given to the provider via the options + s := store.NewFlags() + s.FlagSources = append(s.FlagSources, os.Getenv("FLAGD_SOURCE_URI")) + s.SourceMetadata[provider.URI] = store.SourceDetails{ + Source: os.Getenv("FLAGD_SOURCE_URI"), + Selector: os.Getenv("FLAGD_SOURCE_SELECTOR")), + } + + // derive evaluator + provider.evaluator := setupJSONEvaluator(logger, s) + + return provider +} + +func WithHost(host string) ProviderOption { + return func(p *Provider) { + p.flagdHost = host + } +} + +func (p *Provider) BooleanEvaluation( + ctx context.Context, flagKey string, defaultValue bool, evalCtx of.FlattenedContext, +) of.BoolResolutionDetail { + + res, err := p.evaluator.ResolveBoolean(ctx, flagKey, context) + + if err != nil { + return of.BoolResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewGeneralResolutionError(err.Error()), + Reason: of.Reason(res.Reason), + Variant: res.Variant, + }, + } + } + + return of.BoolResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.Reason(res.Reason), + Variant: res.Variant, + }, + } +} + +// ... +``` + +## Provider lifecycle, initialization and shutdown + +With the release of the v0.6.0 spec, OpenFeature now outlines a lifecycle for in-process flagd provider initialization and shutdown. + +In-process flagd providers should do the following to make use of OpenFeature v0.6.0 features: + +- start in a `NOT_READY` state +- fetch the flag configurations specified in the sync provider sources and set `state` to `READY` or `ERROR` in the `initialization` function + - note that the SDK will automatically emit `PROVIDER_READY`/`PROVIDER_ERROR` according to the termination of the `initialization` function +- throw an exception or terminate abnormally if a connection cannot be established during `initialization` +- For gRPC based sources (i.e. flagd-proxy), attempt to restore the streaming connection to flagd-proxy (if the connection cannot be established or is broken): + - If flag configurations have been retrieved previously, go into `STALE` state to indicate that flag resolution responsees are based on potentially outdated Flag Configurations. + - reconnection should be attempted with an exponential back-off, with a max-delay of `maxSyncRetryInterval` (see [configuration](#configuration)) + - reconnection should be attempted up to `maxSyncRetryDelay` times (see [configuration](#configuration)) + - `PROVIDER_READY` and `PROVIDER_CONFIGURATION_CHANGED` should be emitted, in that order, after successful reconnection +- For Kubernetes sync sources, retry to retrieve the FlagConfiguration resource, using an exponential back-off strategy, with a max-delay of `maxSyncRetryInterval` (see [configuration](#configuration)) +- emit `PROVIDER_CONFIGURATION_CHANGED` event and update ruleset when a `configuration_change` message is received on the streaming connection +- close the streaming connection in the`shutdown` function + +```mermaid +stateDiagram-v2 + [*] --> NOT_READY + NOT_READY --> READY: initialize(), stream connected, flag configurations retrieved + NOT_READY --> ERROR: initialize(), unable to connect (retry) + READY --> STALE: previously retrieved flag configurations can not be retrieved anymore (emit stale*) + STALE --> READY: connection to flag source reestablished, and latest flag configurations retrieved (emit ready*, changed*) + STALE --> ERROR: connection reattempt failed after maxSyncRetries reached (emit error*) + READY --> READY: configuration_change (emit changed*) + ERROR --> READY: reconnect successful (emit ready*, changed*) + ERROR --> ERROR: maxSyncRetries reached + ERROR --> [*]: shutdown(), stream disconnected +``` + +\* ready=`PROVIDER_READY`, changed=`PROVIDER_CONFIGURATION_CHANGED`, stale=`PROVIDER_STALE`, error=`PROVIDER_ERROR` + +## Configuration + +Expose means to configure the provider aligned with the following priority system (highest to lowest). + +```mermaid +flowchart LR + constructor-parameters -->|highest priority| environment-variables -->|lowest priority| defaults +``` + +### Explicit declaration + +This takes the form of parameters to the provider's constructor, it has the highest priority. + +### Environment variables + +Read environment variables with sensible defaults (before applying the values explicitly declared to the constructor). + +| Option name | Environment variable name | Type | Options | Default | +| --------------------------- | ------------------------------------- | ------- | ------------ | -------------------------------------- | +| host | FLAGD_PROXY_HOST | string | | localhost | +| port | FLAGD_PROXY_PORT | number | | 8013 | +| tls | FLAGD_PROXY_TLS | boolean | | false | +| socketPath | FLAGD_PROXY_SOCKET_PATH | string | | | +| certPath | FLAGD_PROXY_SERVER_CERT_PATH | string | | | +| sourceURI | FLAGD_SOURCE_URI | string | | | +| sourceProviderType | FLAGD_SOURCE_PROVIDER_TYPE | string | | grpc | +| sourceSelector | FLAGD_SOURCE_SELECTOR | string | | | +| maxSyncRetries | FLAGD_MAX_SYNC_RETRIES | int | | 0 (0 means unlimited) | +| maxSyncRetryInterval | FLAGD_MAX_SYNC_RETRY_INTERVAL | int | | 60s | + +## Error handling + +Handle flag evaluation errors by using the error constructors exported by the SDK (e.g. `openfeature.NewProviderNotReadyResolutionError(ConnectionError)`), thereby allowing the SDK to parse and handle the error appropriately. + +## Post creation + +The following steps will extend the reach of the newly created provider to other developers of the chosen technology. + +### Open an issue to document the provider + +Create an issue in openfeature.dev [here](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=provider&template=document-provider.yaml&title=%5BProvider%5D%3A+). +This will ensure the provider is added to OpenFeature's website.