defmodule ConfigCat.Hooks do @moduledoc """ Subscribe to events fired by the SDK. Hooks are callback functions that are called by the SDK when certain events happen. Client applications can register more than one callback for each hook. Callbacks are called within the same process that generated the event. Any exceptions that are raised by a callback are rescued, logged, and reported to any registered `on_error` callbacks. The following callbacks are available: - `on_client_ready`: This event is sent when the SDK reaches the ready state. If the SDK is set up with lazy load or manual polling it's considered ready right after instantiation. If it's using auto polling, the ready state is reached when the SDK has a valid config JSON loaded into memory either from cache or from HTTP. - `on_config_changed(config: map())`: This event is sent when the SDK loads a valid config JSON into memory from cache, and each subsequent time when the loaded config JSON changes via HTTP. - `on_flag_evaluated(evaluation_details: EvaluationDetails.t())`: This event is sent each time when the SDK evaluates a feature flag or setting. The event sends the same evaluation details that you would get from get_value_details. - on_error(error: String.t()): This event is sent when an error occurs within the ConfigCat SDK. """ use GenServer alias ConfigCat.Config alias ConfigCat.EvaluationDetails alias ConfigCat.Hooks.Impl defmodule State do @moduledoc false use TypedStruct typedstruct do field :impl, Impl.t() field :instance_id, ConfigCat.instance_id(), enforce: true end @spec new(keyword()) :: t() def new(options \\ []) do hooks = Keyword.get(options, :hooks, []) struct!(__MODULE__, impl: Impl.new(hooks), instance_id: options[:instance_id]) end @spec with_impl(t(), Impl.t()) :: t() def with_impl(%__MODULE__{} = state, %Impl{} = impl) do %{state | impl: impl} end end @typedoc """ A hook callback is either an anonymous function or a module/function name/extra_arguments tuple. Each callback is passed specific arguments. These specific arguments are prepended to the extra arguments provided in the tuple (if any). For example, you might want to define a callback that sends a message to another process which the config changes. You can pass the pid of that process as an extra argument: ```elixir def MyModule do def subscribe_to_config_changes(subscriber_pid) do ConfigCat.hooks() |> ConfigCat.Hooks.add_on_config_changed({__MODULE__, :on_config_changed, [subscriber_pid]}) end def on_config_changed(config, pid) do send pid, {:config_changed, config} end end ``` """ @type named_callback :: {module(), atom(), list()} @type on_client_ready_callback :: (-> any()) | named_callback() @type on_config_changed_callback :: (Config.settings() -> any()) | named_callback() @type on_error_callback :: (String.t() -> any()) | named_callback() @type on_flag_evaluated_callback :: (EvaluationDetails.t() -> any()) | named_callback() @type option :: {:on_client_ready, on_client_ready_callback()} | {:on_config_changed, on_config_changed_callback()} | {:on_error, on_error_callback()} | {:on_flag_evaluated, on_flag_evaluated_callback()} @type start_option :: {:hooks, t()} | {:instance_id, ConfigCat.instance_id()} @opaque t :: ConfigCat.instance_id() @doc false @spec start_link([start_option()]) :: GenServer.on_start() def start_link(options) do instance_id = Keyword.fetch!(options, :instance_id) GenServer.start_link(__MODULE__, State.new(options), name: via_tuple(instance_id)) end @doc """ Add an `on_client_ready` callback. """ @spec add_on_client_ready(t(), on_client_ready_callback()) :: t() def add_on_client_ready(instance_id, callback) do instance_id |> via_tuple() |> GenServer.call({:add_hook, :on_client_ready, callback}) instance_id end @doc """ Add an `on_config_changed` callback. """ @spec add_on_config_changed(t(), on_config_changed_callback()) :: t() def add_on_config_changed(instance_id, callback) do instance_id |> via_tuple() |> GenServer.call({:add_hook, :on_config_changed, callback}) instance_id end @doc """ Add an `on_error` callback. """ @spec add_on_error(t(), on_error_callback()) :: t() def add_on_error(instance_id, callback) do instance_id |> via_tuple() |> GenServer.call({:add_hook, :on_error, callback}) instance_id end @doc """ Add an `on_flag_evaluated` callback. """ @spec add_on_flag_evaluated(t(), on_flag_evaluated_callback()) :: t() def add_on_flag_evaluated(instance_id, callback) do instance_id |> via_tuple() |> GenServer.call({:add_hook, :on_flag_evaluated, callback}) instance_id end @doc false @spec invoke_on_client_ready(t()) :: :ok def invoke_on_client_ready(instance_id) do instance_id |> hooks() |> Impl.invoke_hook(:on_client_ready, []) end @doc false @spec invoke_on_config_changed(t(), Config.settings()) :: :ok def invoke_on_config_changed(instance_id, settings) do instance_id |> hooks() |> Impl.invoke_hook(:on_config_changed, [settings]) end @doc false @spec invoke_on_error(t(), String.t()) :: :ok def invoke_on_error(instance_id, message) do instance_id |> hooks() |> Impl.invoke_hook(:on_error, [message]) end @doc false @spec invoke_on_flag_evaluated(t(), EvaluationDetails.t()) :: :ok def invoke_on_flag_evaluated(instance_id, %EvaluationDetails{} = details) do instance_id |> hooks() |> Impl.invoke_hook(:on_flag_evaluated, [details]) end defp hooks(instance_id) do instance_id |> via_tuple() |> GenServer.call(:hooks) end defp via_tuple(instance_id) do {:via, Registry, {ConfigCat.Registry, {__MODULE__, instance_id}}} end @impl GenServer def init(%State{} = state) do Logger.metadata(instance_id: state.instance_id) {:ok, state} end @impl GenServer def handle_call({:add_hook, hook, callback}, _from, %State{} = state) do new_impl = Impl.add_hook(state.impl, hook, callback) {:reply, :ok, State.with_impl(state, new_impl)} end @impl GenServer def handle_call(:hooks, _from, %State{} = state) do {:reply, state.impl, state} end end