Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/sdk-spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ permissions:
jobs:
sdk-spec:
# Keep the ref in `uses:` and `spec-ref` in sync.
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.3.0
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.7.0
with:
spec-ref: v0.3.0
spec-ref: v0.7.0
language: elixir
21 changes: 21 additions & 0 deletions HEXDOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,21 @@ false = Flagsmith.Client.is_feature_enabled("my-other-feature-b")

When you enable analytics reporting, besides having to start the `Flagsmith.Supervisor` for it to work, you need to query for flags/features/features values through the exposed functions in `Flagsmith.Client.get_flag/2` `Flagsmith.Client.is_feature_enabled/2` and `Flagsmith.Client.get_feature_value/2`. This will update the tracking of the number of times they're accessed.

#### Experiments & Events

To measure experiments, enable events and start the `Flagsmith.Supervisor`. Getting a flag with `Flagsmith.Client.get_experiment_flag/4` records which variant the identity was served, and `Flagsmith.Client.track_event/3` records what the identity did:

```elixir
config = Flagsmith.Client.new(environment_key: "MY_SDK_KEY", enable_events: true)

%Flagsmith.Schemas.Flag{value: "hello treatment"} =
Flagsmith.Client.get_experiment_flag(config, "banner", "user-a", [%{trait_key: "plan", trait_value: "premium"}])

:ok = Flagsmith.Client.track_event(config, "checkout_completed", identifier: "user-a", value: 42, metadata: %{currency: "GBP"})
```

Events are sent in batches, every `events_flush_interval_milliseconds`.

#### Identity & Traits

When using local evaluation with identity based requests the provided traits will not be updated on the Flagsmith platform, and to properly calculate the flags for an identity you need to provide any relevant trait as well. For instance, to get the `flags` for the identity `"user-a"` with a given trait:
Expand Down Expand Up @@ -202,3 +217,9 @@ And obviously, if the identity has that trait and you do the regular API call wi
- `retries` -> the number of times the http adapter is allowed to retry failed calls to the Flagsmith API before deeming the response failed. Keep in mind that with local_evaluation and analytics, even if the requests fail after whatever number of retries you set here, they will keep being retried as their time-cycle resets (for the poller whatever is the environment_refresh_interval_milliseconds, for the analytics they're batched, and a dump is tried every 1 minute)

- `enable_analytics` -> track feature queries by reporting automatically to the Flagsmith API analytics endpoint queries and lookups to features. This only works when using the `Flagsmith.Client` module functions such as `is_feature_enabled`, `get_feature_value` and `get_flag`.

- `enable_events` -> send experiment exposures and tracked events to the Flagsmith events API (defaults to `false`).

- `events_api_url` -> the base url of the Flagsmith events API (defaults to: `"https://events.api.flagsmith.com/"`)

- `events_flush_interval_milliseconds` -> the time to wait between sending batches of events (defaults to 10_000 milliseconds)
32 changes: 28 additions & 4 deletions lib/configuration/flagsmith_configuration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
require Logger

@default_url "https://edge.api.flagsmith.com/api/v1/"
@default_events_url "https://events.api.flagsmith.com/"
@environment_header "X-Environment-Key"

@api_paths %{
flags: "/flags/",
identities: "/identities/",
traits: "/traits/",
analytics: "/analytics/flags/",
environment: "/environment-document/"
environment: "/environment-document/",
events: "/v1/events"
}

@min_refresh_interval if(Mix.env() == :test, do: 1, else: 60_000)
Expand All @@ -24,7 +26,10 @@
enable_local_evaluation: false,
environment_refresh_interval_milliseconds: 60_000,
retries: 0,
enable_analytics: false
enable_analytics: false,
enable_events: false,
events_api_url: @default_events_url,
events_flush_interval_milliseconds: 10_000
]

@default_keys [
Expand All @@ -35,7 +40,10 @@
:enable_local_evaluation,
:environment_refresh_interval_milliseconds,
:retries,
:enable_analytics
:enable_analytics,
:enable_events,
:events_api_url,
:events_flush_interval_milliseconds
]

@type t() :: %__MODULE__{
Expand All @@ -47,12 +55,18 @@
enable_local_evaluation: boolean(),
environment_refresh_interval_milliseconds: non_neg_integer(),
retries: non_neg_integer(),
enable_analytics: boolean()
enable_analytics: boolean(),
enable_events: boolean(),
events_api_url: String.t(),
events_flush_interval_milliseconds: pos_integer()
}

@doc false
def default_url(), do: @default_url

@doc false
def default_events_url(), do: @default_events_url

@doc false
def environment_header(), do: @environment_header

Expand Down Expand Up @@ -96,7 +110,7 @@
:ok

false ->
Logger.warn("unknown option #{inspect(k)} passed as configuration to Flagsmith.Client")

Check warning on line 113 in lib/configuration/flagsmith_configuration.ex

View workflow job for this annotation

GitHub Actions / Build and test

Logger.warn/1 is deprecated. Use Logger.warning/2 instead

Check warning on line 113 in lib/configuration/flagsmith_configuration.ex

View workflow job for this annotation

GitHub Actions / Build and test

Logger.warn/1 is deprecated. Use Logger.warning/2 instead
end
end)

Expand Down Expand Up @@ -144,6 +158,16 @@

:enable_analytics ->
is_boolean(value) || {:error, "needs to be true or false"}

:enable_events ->
is_boolean(value) || {:error, "needs to be true or false"}

:events_api_url ->
is_binary(value) || {:error, "needs to be a String.t()"}

:events_flush_interval_milliseconds ->
(is_integer(value) and value > 0) ||
{:error, "needs to be an integer bigger than 0"}
end
end

Expand Down
190 changes: 186 additions & 4 deletions lib/flagsmith_client.ex
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
defmodule Flagsmith.Client do
require Logger

alias Flagsmith.Schemas
alias Flagsmith.Configuration
alias Flagsmith.Client.Events

@api_paths Flagsmith.Configuration.api_paths()
@environment_header Flagsmith.Configuration.environment_header()
@flag_exposure_event Events.Processor.flag_exposure_event()

@type tesla_header_list :: [{String.t(), String.t()}]
@type config_or_env :: Configuration.t() | Keyword.t() | Schemas.Environment.t()
@type event_value :: String.t() | number() | boolean() | nil
@type event_opts :: [
identifier: String.t(),
value: event_value(),
traits: list(map() | Schemas.Traits.Trait.t()),
metadata: map()
]

@doc false
@spec user_agent() :: String.t()
def user_agent do
@spec version() :: String.t()
def version do
# x-release-please-start-version
version = "2.3.1"
"2.3.1"
# x-release-please-end
"flagsmith-elixir-sdk/#{version}"
end

@doc false
@spec user_agent() :: String.t()
def user_agent, do: "flagsmith-elixir-sdk/#{version()}"

@doc """
Create a `t:Flagsmith.Configuration.t/0` struct with the desired settings to use
in requests.
Expand Down Expand Up @@ -203,6 +217,117 @@ defmodule Flagsmith.Client do
end
end

@doc """
Returns a `t:Flagsmith.Schemas.Flag.t/0` by name, evaluated for an identity as
`get_identity_flags/4` does, and records which variant of the feature the identity
was exposed to.

The exposure is sent as a `"$flag_exposure"` event, valued with the variant the
identity was served, only when the flag is enabled and the identity is enrolled in a
running experiment on the feature.

Requires `:enable_events` to be `true` and the `Flagsmith.Supervisor` to be running.
Returns what `get_flag/2` returns for the identity's flags, or an `:error` tuple if
unable to get them.
"""
@spec get_experiment_flag(
Configuration.t() | Keyword.t(),
feature_name :: String.t(),
identifier :: String.t(),
list(map() | Schemas.Traits.Trait.t())
) :: Schemas.Flag.t() | :not_found | term()
def get_experiment_flag(configuration_or_opts, feature_name, identifier, traits \\ [])

def get_experiment_flag(%Configuration{enable_events: false}, _, _, _),
do: {:error, :events_disabled}

def get_experiment_flag(%Configuration{} = config, feature_name, identifier, traits) do
with {:ok, flags} <- get_identity_flags(config, identifier, traits) do
flag = get_flag(flags, feature_name)

case exposure_skip_reason(flag) do
nil ->
track_exposure_event(config, feature_name,
identifier: identifier,
value: flag.variant,
traits: traits,
metadata: %{experiment_id: flag.experiment.id}
)

reason ->
Logger.debug("Not sending #{@flag_exposure_event}: #{reason}",
feature_name: feature_name
)
end

flag
end
end

def get_experiment_flag(opts, feature_name, identifier, traits) when is_list(opts),
do: get_experiment_flag(new(opts), feature_name, identifier, traits)

@doc """
Sends an event to Flagsmith, e.g. a conversion for experiments to measure.

Options:
- `:identifier` -> the identity the event happened to
- `:value` -> a string, number or boolean, sent as a string
- `:traits` -> the identity's traits, in the format `get_identity_flags/4` takes
- `:metadata` -> a map of additional data

Events are buffered and sent in batches, so this returns `:ok` without waiting for
the event to be sent. Requires `:enable_events` to be `true` and the
`Flagsmith.Supervisor` to be running. Event names starting with `$` are reserved
for Flagsmith's own events.
"""
@spec track_event(Configuration.t() | Keyword.t(), event :: String.t(), event_opts()) ::
:ok | {:error, term()}
def track_event(configuration_or_opts, event, opts \\ [])

def track_event(%Configuration{enable_events: false}, _, _),
do: {:error, :events_disabled}

def track_event(%Configuration{}, "$" <> _ = event, _) when event != @flag_exposure_event,
do: {:error, {:reserved_event_name, event}}

def track_event(%Configuration{} = config, event, opts) when is_binary(event),
do: buffer_event(config, event, nil, opts)

def track_event(opts, event, event_opts) when is_list(opts),
do: track_event(new(opts), event, event_opts)

@doc """
Sends a `"$flag_exposure"` event to Flagsmith, recording that an identity was shown
a variant of a feature. `get_experiment_flag/4` sends these by itself.

Takes the same options as `track_event/3`, with `:value` being the variant, and
`:identifier` being required to reconcile the exposure with the identity's events.
Exposures equal in feature, identifier and value are sent once per flush interval.
"""
@spec track_exposure_event(
Configuration.t() | Keyword.t(),
feature_name :: String.t(),
event_opts()
) :: :ok | {:error, term()}
def track_exposure_event(configuration_or_opts, feature_name, opts \\ [])

def track_exposure_event(%Configuration{enable_events: false}, _, _),
do: {:error, :events_disabled}

def track_exposure_event(%Configuration{} = config, feature_name, opts) do
case Keyword.get(opts, :identifier) do
identifier when is_binary(identifier) and identifier != "" ->
buffer_event(config, @flag_exposure_event, feature_name, opts)

_ ->
{:error, :identifier_required}
end
end

def track_exposure_event(opts, feature_name, event_opts) when is_list(opts),
do: track_exposure_event(new(opts), feature_name, event_opts)

@doc """
Return all flags from an environment as a list.
If a `t:Flagsmith.Schemas.Environment.t/0` is passed instead of a
Expand Down Expand Up @@ -356,6 +481,24 @@ defmodule Flagsmith.Client do
when is_list(opts) and is_map(tracking) and not is_struct(tracking),
do: analytics_track(new(opts), tracking)

@doc false
# Submits a batch of events to the Flagsmith events API.
@spec events_track(Configuration.t(), list(Events.Processor.event())) ::
{:ok, term()} | {:error, term()}
def events_track(%Configuration{events_api_url: events_api_url} = config, events) do
case Tesla.post(
http_client(%{config | api_url: events_api_url}),
@api_paths.events,
%{events: events}
) do
{:ok, %{status: status, body: body}} when status >= 200 and status < 300 ->
{:ok, body}

error_resp ->
return_error(error_resp)
end
end

@doc false
# Given an `t:Flagsmith.Schemas.Environment.t/0` or a list composed of
# `t:Flagsmith.Schemas.Environment.FeatureState.t/0` or
Expand Down Expand Up @@ -387,6 +530,45 @@ defmodule Flagsmith.Client do
feature_flag
end

defp exposure_skip_reason(%Schemas.Flag{enabled: false}), do: "feature is disabled"

defp exposure_skip_reason(%Schemas.Flag{experiment: %{in_experiment: true}, variant: nil}),
do: "flag has no variant"

defp exposure_skip_reason(%Schemas.Flag{experiment: %{in_experiment: true}}), do: nil

defp exposure_skip_reason(%Schemas.Flag{}), do: "identity is not in a running experiment"
defp exposure_skip_reason(_), do: "feature not found"

defp buffer_event(%Configuration{} = config, event, feature_name, opts) do
with {:ok, value} <- event_value(Keyword.get(opts, :value)) do
Events.Processor.track(
%{
event: event,
feature_name: feature_name,
identifier: Keyword.get(opts, :identifier),
value: value,
traits: event_traits(Keyword.get(opts, :traits)),
metadata: Map.put(Keyword.get(opts, :metadata) || %{}, :sdk_version, version()),
timestamp: System.os_time(:millisecond)
},
config
)
end
end

defp event_value(value) when is_nil(value) or is_binary(value), do: {:ok, value}
defp event_value(value) when is_number(value) or is_boolean(value), do: {:ok, to_string(value)}
defp event_value(value), do: {:error, {:invalid_value, value}}

defp event_traits([_ | _] = traits) do
traits
|> Schemas.Traits.Trait.from()
|> Map.new(fn %{trait_key: key, trait_value: value} -> {key, value} end)
end

defp event_traits(_), do: nil

defp build_identity_params(identifier, [_ | _] = traits, transient) do
%{
identifier: identifier,
Expand Down
Loading
Loading