defmodule BilldogEng do @moduledoc """ The BilldogEng server SDK client for Elixir. Engagement suite for server-side use: Analytics, Feature Flags (remote + local evaluation), Surveys (data API), Messaging dispatch, and LLM tracing. ## Quickstart {:ok, bd} = BilldogEng.new("bd_test_xxx", local_evaluation: true) BilldogEng.capture(bd, "user-123", "order_completed", %{"revenue" => 49.99}) on? = BilldogEng.is_feature_enabled(bd, "new_checkout", "user-123") BilldogEng.shutdown(bd) The client holds two supervising-free `GenServer`s — one for the analytics batch queue, one for the feature-flag definition cache. Call `shutdown/1` to flush remaining events and stop the background flush timer. """ alias BilldogEng.{Analytics, Flags, Llm, Messaging, Surveys, Transport} @enforce_keys [:api_key, :transport, :analytics, :flags] defstruct [:api_key, :transport, :analytics, :flags] @type t :: %__MODULE__{ api_key: String.t(), transport: Transport.config(), analytics: pid(), flags: pid() } @defaults [ host: "https://api.billdog.io/v1", flush_at: 20, flush_interval: 10_000, max_queue_size: 1000, gzip: true, local_evaluation: false, request_timeout: 10_000, max_retries: 3, enable_logging: false ] @doc """ Construct a client. ## Options * `:host` — base URL (default `"https://api.billdog.io/v1"`) * `:flush_at` — batch size that triggers a flush (default `20`) * `:flush_interval` — background flush cadence in ms (default `10_000`) * `:max_queue_size` — max queued events before oldest are dropped (default `1000`) * `:gzip` — gzip large request bodies (default `true`) * `:local_evaluation` — enable local feature-flag evaluation (default `false`) * `:request_timeout` — per-request timeout in ms (default `10_000`) * `:max_retries` — retry attempts for 5xx/network errors (default `3`) * `:group_type_index` — map of group-type → positional index `0..4` * `:enable_logging` — emit verbose diagnostics (default `false`) * `:sleep_fn` — injectable sleep (tests) """ @spec new(String.t(), keyword()) :: {:ok, t()} | {:error, term()} def new(api_key, opts \\ []) def new(api_key, _opts) when api_key in [nil, ""] do {:error, "BilldogEng: api_key is required"} end def new(api_key, opts) do config = Keyword.merge(@defaults, drop_nils(opts)) transport = Transport.config( host: config[:host], request_timeout: config[:request_timeout], gzip: config[:gzip], max_retries: config[:max_retries], enable_logging: config[:enable_logging], sleep_fn: Keyword.get(opts, :sleep_fn, &Process.sleep/1) ) {:ok, analytics} = Analytics.start_link( api_key: api_key, transport: transport, flush_at: config[:flush_at], flush_interval: config[:flush_interval], max_queue_size: config[:max_queue_size], group_type_index: config[:group_type_index], enable_logging: config[:enable_logging] ) {:ok, flags} = Flags.start_link( api_key: api_key, transport: transport, local_evaluation: config[:local_evaluation], enable_logging: config[:enable_logging] ) {:ok, %__MODULE__{ api_key: api_key, transport: transport, analytics: analytics, flags: flags }} end @doc "Like `new/2` but raises on error." @spec new!(String.t(), keyword()) :: t() def new!(api_key, opts \\ []) do case new(api_key, opts) do {:ok, client} -> client {:error, reason} -> raise ArgumentError, to_string(reason) end end # ── Analytics ──────────────────────────────────────────────────────────────── @doc "Capture an event for a user. Batched; flushed per the configured policy." @spec capture(t(), String.t(), String.t(), map(), map()) :: :ok def capture(%__MODULE__{} = c, distinct_id, event, properties \\ %{}, groups \\ %{}) do Analytics.capture(c.analytics, distinct_id, event, properties, groups) end @doc "Set person properties. Emits `$identify`." @spec identify(t(), String.t(), map()) :: :ok def identify(%__MODULE__{} = c, distinct_id, properties \\ %{}) do Analytics.identify(c.analytics, distinct_id, properties) end @doc "Set group properties. Emits `$groupidentify`." @spec group_identify(t(), String.t(), String.t(), map()) :: :ok def group_identify(%__MODULE__{} = c, group_type, group_key, properties \\ %{}) do Analytics.group_identify(c.analytics, group_type, group_key, properties) end @doc "Alias one distinct id to another. Emits `$create_alias`." @spec alias(t(), String.t(), String.t()) :: :ok def alias(%__MODULE__{} = c, distinct_id, alias) do Analytics.alias(c.analytics, distinct_id, alias) end @doc "Flush queued analytics events now." @spec flush(t()) :: :ok | {:error, BilldogEng.Error.t()} def flush(%__MODULE__{} = c), do: Analytics.flush(c.analytics) @doc "Number of events currently queued (test/observability aid)." @spec queue_length(t()) :: non_neg_integer() def queue_length(%__MODULE__{} = c), do: Analytics.queue_length(c.analytics) # ── Feature flags ──────────────────────────────────────────────────────────── @doc "Get a flag value: boolean, variant key string, or nil if unknown." @spec get_feature_flag(t(), String.t(), String.t(), keyword()) :: boolean() | String.t() | nil def get_feature_flag(%__MODULE__{} = c, key, distinct_id, opts \\ []) do Flags.get_feature_flag(c.flags, key, distinct_id, opts) end @doc "Whether a flag is enabled for the user." @spec is_feature_enabled(t(), String.t(), String.t(), keyword()) :: boolean() def is_feature_enabled(%__MODULE__{} = c, key, distinct_id, opts \\ []) do Flags.is_feature_enabled(c.flags, key, distinct_id, opts) end @doc "Get a flag's payload (variant config), local mode." @spec get_feature_flag_payload(t(), String.t(), String.t(), keyword()) :: term() def get_feature_flag_payload(%__MODULE__{} = c, key, distinct_id, opts \\ []) do Flags.get_feature_flag_payload(c.flags, key, distinct_id, opts) end @doc "Evaluate all known flags for a user." @spec get_all_flags(t(), String.t(), keyword()) :: map() def get_all_flags(%__MODULE__{} = c, distinct_id, opts \\ []) do Flags.get_all_flags(c.flags, distinct_id, opts) end @doc "Reload local flag definitions (local mode)." @spec reload_feature_flag_definitions(t()) :: :ok def reload_feature_flag_definitions(%__MODULE__{} = c) do Flags.reload_feature_flag_definitions(c.flags) end @doc "Inject flag definitions directly, bypassing the network (advanced/tests)." @spec set_definitions(t(), [map()]) :: :ok def set_definitions(%__MODULE__{} = c, defs) do Flags.set_definitions(c.flags, defs) end # ── Surveys / Messaging / LLM ──────────────────────────────────────────────── @doc "Dispatch a message. See `BilldogEng.Messaging.dispatch/2`." @spec dispatch_message(t(), keyword()) :: {:ok, term()} | {:error, BilldogEng.Error.t()} def dispatch_message(%__MODULE__{} = c, params), do: Messaging.dispatch(c, params) @doc "Capture an LLM trace span. See `BilldogEng.Llm.capture_trace/2`." @spec capture_trace(t(), keyword()) :: {:ok, term()} | {:error, BilldogEng.Error.t()} def capture_trace(%__MODULE__{} = c, params), do: Llm.capture_trace(c, params) # ── Lifecycle ──────────────────────────────────────────────────────────────── @doc "Flush remaining events and stop the background flush timer." @spec shutdown(t()) :: :ok | {:error, BilldogEng.Error.t()} def shutdown(%__MODULE__{} = c) do result = Analytics.shutdown(c.analytics) GenServer.stop(c.analytics) GenServer.stop(c.flags) result end # Convenience pass-throughs for surveys so callers can write # `BilldogEng.list_surveys(client, ...)` etc. without juggling the struct. defdelegate list_surveys(client, opts \\ []), to: Surveys, as: :list defdelegate fetch_survey(client, survey_id, opts \\ []), to: Surveys, as: :fetch defdelegate start_survey(client, survey_id, context \\ []), to: Surveys, as: :start defdelegate record_partial(client, survey_id, respondent_id, answers, context \\ []), to: Surveys, as: :record_partial defdelegate submit_survey(client, survey_id, answers, context \\ []), to: Surveys, as: :submit defdelegate abandon_survey(client, survey_id, respondent_id), to: Surveys, as: :abandon defp drop_nils(opts) do Enum.reject(opts, fn {_k, v} -> is_nil(v) end) end end