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