Skip to main content

lib/billdog_eng.ex

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