Unleash SDK (Fresha Edition)

An Elixir SDK for the Unleash Feature Flag System.

Originally started as a fork of unleash_ex, but since then it has diverged quite a bit, most notably by replacing Mojito with Req, changing its usage APIs in version 2.0.0 and by implementing custom extensions like Propagation.

Take a look at the CHANGELOG, the two projects diverged on version 1.9.0.

Installation

Add unleash_fresha to your dependendencies in mix.exs:

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.

# 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 downcased string derived from its name, so :my_feature, "my_feature" and "MY_FEATURE" all resolve to the same flag. (Atom names are assumed to already be lowercase: :MY_FEATURE will not match.)

Lookups never create atoms: flag names passed as binaries are caller-controlled and potentially unbounded (e.g. dynamically built names), and atoms are never garbage-collected, so interning them would leak the atom table.

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.

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), or hand-write one — app-pay-runs does the latter with its PayRuns.Unleash module, one thin function per flag.

# 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)
# 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

Configuration

There are many configuration options available, and they are listed below with their defaults. These go into the relevant config/*.exs file.

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.

: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):

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 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.

    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
  2. Create a new strategies module. See Unleash.Strategies for details on the correct API.

    defmodule MyApp.Strategies do
    @behaviour Unleash.Strategies
    def strategies do
    [{"environment", MyApp.Strategy.Environment}] ++ Unleash.Strategies.strateges()
    end
    end
  3. Configure your application to use your new strategies list.

    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:

# 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:

EventWhenMeasurementMetadata
[: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()
[: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()}
EventWhenMeasurementMetadata
[: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()}
EventWhenMeasurementMetadata
[: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()}
EventWhenMeasurementMetadata
[: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() } } } } }
EventWhenMetadata
[: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 which needs to be cloned to priv folder