Packages
unleash_fresha
5.1.2
5.1.4
5.1.3
5.1.2
5.1.1
5.1.0
5.0.0
4.0.0
4.0.0-git-bda3
3.0.1
3.0.0
2.1.0
2.0.1
2.0.0-git-75d8
2.0.0-git-67b2
2.0.0-git-4b61
2.0.0-git-49f4
1.16.0
1.15.0
1.14.1-git-e754
1.14.0
1.14.0-git-71e9
1.13.0
1.13.0-git-4ba6
1.12.0
1.11.0
1.11.0-git-1b62
1.10.2
1.10.2-git-b835
1.10.1
1.10.0
1.9.3-alpha0
1.9.2
1.9.1
1.9.0
An Unleash Feature Flag client for Elixir, forked from [unleash](https://gitlab.com/afontaine/unleash_ex)
Current section
Files
Jump to
Current section
Files
unleash_fresha
README.md
README.md
# Unleash SDK (Fresha Edition)
An Elixir SDK for the [Unleash Feature Flag System](https://www.getunleash.io/).
Originally started as a fork of [unleash_ex](https://gitlab.com/afontaine/unleash_ex/),
but since then it has diverged quite a bit,
most notably by replacing [Mojito](https://github.com/appcues/mojito) with [Req](https://github.com/wojtekmach/req),
changing its usage APIs in version 2.0.0 and by implementing custom extensions like
[Propagation](https://hexdocs.pm/unleash_fresha/Unleash.Propagation.html).
Take a look at the [CHANGELOG](./CHANGELOG.md), the two projects diverged on version 1.9.0.
## Installation
Add `unleash_fresha` to your dependendencies in `mix.exs`:
```elixir
def deps do
[
{:unleash_fresha, "~> 2.0"}
]
end
```
## Usage
Use `Unleash.Macros.enabled?/2` (or `/1`) and `Unleash.Macros.get_variant/2` (or `/1`) to check if a feature
is enabled and get the right variant, in case of features with variants.
```elixir
# You have to require the `Unleash.Macros` module.
# In this example we alias it to `Unleash` to make it nicer, but you do you.
iex> require Unleash.Macros, as: Unleash
iex> Unleash.enabled?(:some_feature, fallback: false, context: %{user_id: "u-123"})
false
iex> Unleash.get_variant(:some_feature_with_variants, context: %{properties: %{"lucky" => "yes"}})
%{enabled: true, name: "variant_c", payload: %{}}
```
The macros do some static checks at compile-time for safer usage,
and allow you to swap the runtime implementation that does the feature check,
for example to use a mock one in the test environment (see next section).
If for some reason you can't use the macros, you can call the runtime equivalents in `Unleash.Runtime` directly,
but by doing so you lose the benefits mentioned above.
Check the moduledocs of `Unleash`, `Unleash.Runtime` and `Unleash.Macros` for more details.
### Feature name matching
Feature names are matched case-insensitively. Internally the local cache stores
each feature under a **lowercase atom** derived from its name, so `:my_feature`,
`"my_feature"` and `"MY_FEATURE"` all resolve to the same flag.
Passing an already-lowercase atom (e.g. `Unleash.enabled?(:my_feature)`) is the
fastest lookup path, since it is used as the ETS key directly with no
conversion; passing a binary downcases and atomizes it on every call. Feature
names are bounded by the number of flags, so creating atoms for them is safe.
> **Note:** case-insensitive matching is a behaviour of this SDK and is **not**
> part of the upstream Unleash specification — the official client-specification
> has no tests for feature flags that differ only in case. We treat it as a
> reasonable assumption that `FeatureFlag` and `featureflag` refer to the same
> flag and should not behave differently.
## Testing of code that uses feature flags
In tests you usually don't want to reach a real Unleash server, and you want to
force specific flag values to exercise each code branch. At Fresha we do this
with [`Mimic`](https://github.com/edgurgel/mimic).
The cleanest approach is to route every flag check through a small **facade**
module with one function per flag, then stub those functions in tests. You can
generate the facade with `Unleash.Flags` (see
[Generated flag facade](#generated-flag-facade)), or hand-write one —
[`app-pay-runs`](https://github.com/freshaengineering/app-pay-runs) does the
latter with its `PayRuns.Unleash` module, one thin function per flag.
```elixir
# The facade — one function per flag. `Unleash.Flags` needs a concrete list of
# flag names at compile time (via `:features` or the `:allowed_features` config);
# a hand-written facade needs no list.
defmodule MyApp.Features do
use Unleash.Flags, features: [:my_flag]
end
# config/test.exs — keep the client off so any un-stubbed flag falls through to
# its safe `fallback:` instead of hitting the network.
config :unleash_fresha, Unleash, disable_client: true
# test/test_helper.exs — register the facade for stubbing (once, at startup)
Mimic.copy(MyApp.Features)
```
```elixir
# In a test
use Mimic
setup :verify_on_exit!
# stub/3 forces a value for the whole test, no call-count assertion
test "returns :yes when the flag is enabled" do
stub(MyApp.Features, :my_flag, fn _context -> true end)
assert :yes == SomeModule.some_function()
end
# expect/3 additionally asserts the flag is checked (verified by verify_on_exit!)
# and lets you assert on the context passed in
test "checks the flag before acting" do
expect(MyApp.Features, :my_flag, fn context ->
assert context.user_id == "u-123"
true
end)
assert :yes == SomeModule.some_function()
end
```
If your code calls `Unleash.enabled?/2` directly rather than through a facade,
you can't stub the compile-time macro — instead stub the runtime it delegates to:
`Mimic.copy(Unleash.Runtime)` then
`stub(Unleash.Runtime, :enabled?, fn _flag, _opts -> true end)`. A per-flag
facade is preferred though: it keeps call sites readable and lets you stub one
flag at a time.
## Migrating from `unleash_ex`
- change the dependency name and version in mix.exs
- rename `:uneash` to `:unleash_fresha` in config files
- replace calls to functions `is_enabled/1,2,3`, `enabled?/1,2,3` and `get_variant/1,2,3` to the
correspondent invokations of the macros `enabled?/1,2` and `get_variant/1,2`.
See the [CHANGELOG](./CHANGELOG.md) for version 2.0.0 and the docs of the macros for more details.
- use any of the extra features provided by `unleash_fresha`, e.g. the gRPC interceptors.
## Configuration
There are many configuration options available, and they are listed below with
their defaults. These go into the relevant `config/*.exs` file.
```elixir
config :unleash_fresha, Unleash,
url: "", # The URL of the Unleash server to connect to, should include up to http://base.url/api
appname: "", # The app name, used for registration
instance_id: "", # The instance ID, used for metrics tracking
metrics_period: 10 * 60 * 1000, # Send metrics every 10 minutes, in milliseconds
features_period: 15 * 1000, # Poll for new flags every 15 seconds, in milliseconds
strategies: Unleash.Strategies, # Which module to request for toggle strategies
backup_file: nil, # Backup file in the event that contacting the server fails
custom_http_headers: [], # A keyword list of custom headers to send to the server
runtime: Unleash.Runtime, # the Runtime module which will be used for feature flags checks
disable_client: false, # Whether or not to enable the client
disable_metrics: false, # Whether or not to send metrics,
retries: -1 # How many times to retry on failure, -1 disables limit
app_env: :dev, # Which environment we're in
allowed_features: nil # Only cache these features (list of names); nil caches all
```
`:custom_http_headers` should follow [the format prescribed by Req](https://hexdocs.pm/req/Req.html#module-header-names).
`:allowed_features` lets a service that only uses a few flags keep its cache
minimal. When set to a list of feature names (strings or atoms), only those
features are stored in the ETS cache; every other feature returned by the
Unleash server is discarded. When `nil` (the default), all features are cached.
### Generated flag facade
`Unleash.Flags` generates a module with one function per flag, so you can write
`MyApp.Features.my_flag(context)` instead of
`Unleash.enabled?(:my_flag, context: context)`:
```elixir
defmodule MyApp.Features do
use Unleash.Flags
end
MyApp.Features.my_flag(%{user_id: "42"})
```
Functions are generated at compile time from `:allowed_features` (so you get
autocomplete and no "undefined function" warnings), and each one queries the
cache through the configured runtime. The function name defaults to the
downcased flag name (no `?` is appended). You can map flag names to friendlier
function names with `:transform` — e.g. turning `TEAM_X_FEATURE_Y` into
`feature_y_enabled?/1` (adding the `?` yourself) while still querying
`:team_x_feature_y`. See `Unleash.Flags` for the full option list.
`:strategies` should be a module that implements
`c:Unleash.Strategies.strategies/0`. See [Extensibility](#extensibility)
for more information.
## Extensibility
If you need to create your own strategies, you can extend the `Unleash` client
by implementing the callbacks in both `Unleash.Strategy` and
`Unleash.Strategies` as well as passing your new `Strategies` module in as
configuration:
1. Create your new strategy. See `Unleash.Strategy` for details on the correct
API and `Unleash.Strategy.Utils` for helpful utilities.
```elixir
defmodule MyApp.Strategy.Environment do
use Unleash.Strategy
def enabled?(%{"environments" => environments}, _context) do
with {:ok, environment} <- MyApp.Config.get_environment(),
environment = List.to_string(environment) do
{Utils.in_list?(environments, environment, &String.downcase/1),
%{environment: environment, environments: environments}}
end
end
end
```
1. Create a new strategies module. See `Unleash.Strategies` for details on the correct
API.
```elixir
defmodule MyApp.Strategies do
@behaviour Unleash.Strategies
def strategies do
[{"environment", MyApp.Strategy.Environment}] ++ Unleash.Strategies.strateges()
end
end
```
1. Configure your application to use your new strategies list.
```elixir
config :unleash_fresha, Unleash, strategies: MyApp.Strategies
```
## Telemetry events
From Unleash 1.9, telemetry events are emitted by the Unleash client
library. You can attach to these events and collect metrics or use the `Logger`,
for example:
```elixir
# An example of checking if Unleash server is reachable during the periodic
# features fetch.
:ok =
:telemetry.attach_many(
:duffel_core_feature_heatbeat_metric,
[
[:unleash, :client, :fetch_features, :stop],
[:unleash, :client, :fetch_features, :exception]
],
fn [:unleash, :client, :fetch_features, action],
_measurements,
metadata,
_config ->
require Logger
http_status = metadata[:http_response_status]
if action == :stop and http_status in [200, 304] do
Logger.info("Fetching features are ok")
else
Logger.info("Error on fetching features!!!")
end
end,
%{}
)
```
The following events are emitted by the Unleash library:
| Event | When | Measurement | Metadata |
| --- | --- | --- | --- |
| `[:unleash, :feature, :enabled?, :start]` | dispatched by Unleash whenever a feature state has been requested.| `%{system_time: system_time, monotonic_time: monotonic_time}` | `%{appname: String.t(), instance_id: String.t(), feature: String.t()}` |
| `[:unleash, :feature, :enabled?, :stop]` | dispatched by Unleash whenever a feature check has successfully returned a result. | `%{duration: native_time, monotonic_time: monotonic_time}` | `%{appname: String.t(), instance_id: String.t(), feature: String.t(), result: boolean() | nil, reason: atom(), strategy_evaluations: [{String.t(), boolean()}], feature_enabled: boolean()}` |
| `[:unleash, :feature, :enabled?, :exception]` | dispatched by Unleash after exceptions on fetching a feature's activation state. | `%{duration: native_time, monotonic_time: monotonic_time}` | `%{appname: String.t(), instance_id: String.t(), feature: String.t(), kind: :throw \| :error \| :exit, reason: term(), stacktrace: Exception.stacktrace()}` |
| Event | When | Measurement | Metadata |
| --- | --- | --- | --- |
| `[:unleash, :client, :fetch_features, :start]` | dispatched by Unleash.Client whenever it start to fetch features from a remote Unleash server. | `%{system_time: system_time, monotonic_time: monotonic_time}` | `%{appname: String.t(), instance_id: String.t(), etag: String.t() \| nil, url: String.t()}` |
| `[:unleash, :client, :fetch_features, :stop]`| dispatched by Unleash.Client whenever it finishes to fetch features from a remote Unleash server. | `%{duration: native_time, monotonic_time: monotonic_time}` | `%{appname: String.t(), instance_id: String.t(), etag: String.t() \| nil, url: String.t(), http_response_status: pos_integer \| nil, error: struct() \| nil}` |
| `[:unleash, :client, :fetch_features, :exception]` | dispatched by Unleash.Client after exceptions on fetching features. | `%{duration: native_time, monotonic_time: monotonic_time}` | `%{appname: String.t(), instance_id: String.t(), etag: String.t() \| nil, url: String.t(), kind: :throw \| :error \| :exit, reason: term(), stacktrace: Exception.stacktrace()}` |
| Event | When | Measurement | Metadata |
| --- | --- | --- | --- |
| `[:unleash, :client, :register, :start]` | dispatched by Unleash.Client whenever it starts to register in an Unleash server. | `%{system_time: system_time, monotonic_time: monotonic_time}` | `%{appname: String.t(), instance_id: String.t(), url: String.t(), sdk_version: String.t(), strategies: [String.t()], interval: pos_integer}` |
| `[:unleash, :client, :register, :stop]` | dispatched by Unleash.Client whenever it finishes to register in an Unleash server. | `%{duration: native_time, monotonic_time: monotonic_time}` | `%{appname: String.t(), instance_id: String.t(), url: String.t(), sdk_version: String.t(), strategies: [String.t()], interval: pos_integer, http_response_status: pos_integer \| nil, error: struct() \| nil}` |
| `[:unleash, :client, :register, :exception]` | dispatched by Unleash.Client after exceptions on registering in an Unleash server. | `%{duration: native_time, monotonic_time: monotonic_time}` | `%{appname: String.t(), instance_id: String.t(), url: String.t(), sdk_version: String.t(), strategies: [String.t()], interval: pos_integer, kind: :throw \| :error \| :exit, reason: term(), stacktrace: Exception.stacktrace()}` |
| Event | When | Measurement | Metadata |
| --- | --- | --- | --- |
| `[:unleash, :client, :push_metrics, :start]` | dispatched by Unleash.Client whenever it starts to push metrics to an Unleash server. | `%{system_time: system_time, monotonic_time: monotonic_time}` | `%{appname: String.t(), instance_id: String.t(), url: String.t(), metrics_payload: %{ :bucket => %{:start => String.t(), :stop => String.t(), toggles: %{ String.t() => %{ :yes => pos_integer(), :no => pos_integer() } } } } }` |
| `[:unleash, :client, :push_metrics, :stop]` | dispatched by Unleash.Client whenever it finishes to push metrics to an Unleash server. | `%{duration: native_time, monotonic_time: monotonic_time}` | `%{appname: String.t(), instance_id: String.t(), url: String.t(), http_response_status: pos_integer \| nil, error: struct() \| nil, metrics_payload: %{ :bucket => %{:start => String.t(), :stop => String.t(), toggles: %{ String.t() => %{ :yes => pos_integer(), :no => pos_integer() } } } } }` |
| `[:unleash, :client, :push_metrics, :exception]` | dispatched by Unleash.Client after exceptions on pushing metrics to an Unleash server. | `%{duration: native_time, monotonic_time: monotonic_time}` | `%{appname: String.t(), instance_id: String.t(), url: String.t(), kind: :throw \| :error \| :exit, reason: term(), stacktrace: Exception.stacktrace(), metrics_payload: %{ :bucket => %{:start => String.t(), :stop => String.t(), toggles: %{ String.t() => %{ :yes => pos_integer(), :no => pos_integer() } } } } }`
| Event | When | Metadata |
| ----------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `[:unleash, :repo, :schedule]` | dispatched by Unleash.Repo when scheduling a poll to the server for metrics | `%{appname: String.t(), instance_id: String.t(), retries: integer(), etag: String.t(), interval: pos_integer()}` |
| `[:unleash, :repo, :backup_file_update]` | dispatched by Unleash.Repo when it writes features to the backup file. | `%{appname: String.t(), instance_id: String.t(), content: String.t(), filename: String.t()}` |
| `[:unleash, :repo, :disable_polling]` | dispatched by Unleash.Repo when polling gets disabled due to retries running out or zero retries being specified initially. | `%{appname: String.t(), instance_id: String.t(), retries: integer(), etag: String.t()}` |
| `[:unleash, :repo, :features_update]` | dispatched by Unleash.Repo when features are updated. | `%{appname: String.t(), instance_id: String.t(), retries: integer(), etag: String.t(), source: :remote \| :cache \| :backup_file}` |
## Testing
Tests are using upstream [client-specification](https://github.com/Unleash/client-specification) which needs to be cloned to priv folder