billdogeng (Elixir)

Official BilldogEng server SDK for Elixir — the engagement suite for server-side use: Analytics, Feature Flags (remote + local evaluation), Surveys (data API), Messaging dispatch, and LLM observability.

This is the Elixir port of the canonical BilldogEng server SDK. The wire contract and method surface match every other server SDK (billdogeng-{node,python,go,…}) so the cross-language parity corpus passes — in particular the murmurhash3 bucketing primitive reproduces the exact same flag buckets on web / iOS / Android / every server.

Installation

Add billdogeng to your mix.exs deps:

def deps do
  [
    {:billdogeng, "~> 1.0"}
  ]
end

Quickstart

{:ok, bd} = BilldogEng.new("bd_test_xxx", local_evaluation: true)

# Analytics (batched + background flush + gzip + backoff retry)
BilldogEng.capture(bd, "user-123", "order_completed", %{"revenue" => 49.99})
BilldogEng.identify(bd, "user-123", %{"email" => "a@b.com", "plan" => "pro"})
BilldogEng.group_identify(bd, "company", "acme", %{"seats" => 50})
BilldogEng.alias(bd, "user-123", "anon-abc")

# Feature flags (local deterministic evaluation)
true = BilldogEng.is_feature_enabled(bd, "new_checkout", "user-123")
variant = BilldogEng.get_feature_flag(bd, "beta", "user-123",
  person_properties: %{"plan" => "pro"})

# Surveys (data API — no UI rendering)
{:ok, surveys} = BilldogEng.list_surveys(bd, distinct_id: "user-123")
{:ok, config}  = BilldogEng.fetch_survey(bd, survey_id, distinct_id: "user-123")
{:ok, %{"respondent_id" => rid}} = BilldogEng.start_survey(bd, survey_id, customer_id: "user-123")
{:ok, _} = BilldogEng.submit_survey(bd, survey_id,
  [%{question_id: q_id, choice_id: c_id, answer_number: 9}],
  respondent_id: rid)

# Messaging dispatch (Bearer JWT auth)
{:ok, %{"sent" => n}} = BilldogEng.dispatch_message(bd,
  project_id: project_id,
  channel: "push",
  content: %{"title" => "Hi", "body" => "There"},
  targeting: %{"type" => "all"},
  access_token: jwt)

# LLM observability
{:ok, _} = BilldogEng.capture_trace(bd,
  trace_id: "t-1", span_id: "s-1", model: "claude-opus-4-8",
  input_text: "hello", output_text: "hi",
  prompt_tokens: 10, completion_tokens: 5, duration_ms: 123, cost_usd: 0.002)

# Flush remaining events and stop the background timer.
BilldogEng.shutdown(bd)

Configuration

BilldogEng.new/2 options (all optional):

Option Default Description
:host "https://api.billdog.io/v1" Base URL for all API requests
:flush_at 20 Batch size that triggers a flush
:flush_interval 10_000 Background flush cadence (ms)
:max_queue_size 1000 Max queued events before oldest dropped
:gzip true Gzip large request bodies (≥ 1 KiB)
:local_evaluation false Enable local feature-flag evaluation
:request_timeout 10_000 Per-request timeout (ms)
:max_retries 3 Retry attempts for 5xx / network errors
:enable_logging false Verbose diagnostics via Logger

Auth uses the X-BillDog-API-Key header on every request (bd_test_* sandbox / bd_live_* live). Messaging dispatch instead authenticates with a Supabase session Bearer JWT (:access_token); LLM tracing uses X-BillDog-API-Key.

Feature-flag evaluation

In local_evaluation: true mode, definitions are fetched once from POST /feature-flag-definitions, cached with a 5-minute TTL, and evaluated deterministically on this process:

  1. Missing/inactive → false.
  2. The flag's targeting_rule must match, else false. This is the CANONICAL audience DSL — the same rule object the backend evaluates — resolved by BilldogEng.Targeting: AND/OR combinators, rule status, date windows, the full operator set, and group membership all evaluate locally, with no server round-trip. Context comes from the eval options — :person_properties, :groups, :country, :platform, :app_version, :sdk_version, :experiment_variants (the four scalar ones fall back to a same-named person property).
  3. bucket = murmurhash3("flag:{key}:{distinct_id}") % 100; ON iff bucket < rollout_percentage.
  4. Multivariate: walk variants by cumulative rollout within the ON bucket.

Conditions that need the DATABASE (segment, cohort, survey_answer, event_fired_in_window, group_property) are never guessed at: they go COLD and the flag is resolved via POST /experiment-config instead. "We don't know" and "not in the audience" are different answers.

BilldogEng.set_definitions/2 injects definitions directly (tests / hosts that distribute definitions through their own channel).

Architecture

The client holds two GenServers: one for the analytics batch queue (with the background flush timer), one for the feature-flag definition cache. Surveys, Messaging, and Llm are stateless and called with the %BilldogEng{} struct. HTTP runs on Erlang's built-in :httpc / :zlib, so the only runtime dependency is jason.

Tests

mix deps.get && mix test

Coverage mirrors the reference SDK:

  1. Batching — 10 captures → ONE batched POST with 10 events.
  2. Identify/group/alias — correct event_name + properties.
  3. Local flag bucketing — the 12 canonical murmurhash3 vectors + rollout / targeting / multivariate cases. 3b. Targeting conformance — test/targeting_conformance_test.exs asserts the audience evaluator against tests/fixtures/flag-targeting.json, generated FROM the backend evaluator (never from a sibling SDK).
  4. Retry — 503 then 200 succeeds after backoff (against a local stub).
  5. Survey round-trip + messaging dispatch + LLM trace payload shapes.

BillDog Auth

Session-token verification is not available in this SDK yet. billdogeng-node is the only server SDK that can verify a BillDog Auth token offline today.

Until it lands here, either verify in a Node service, or call the auth-introspect endpoint over HTTP — that costs a network round-trip per check, and in exchange tells you whether the session is live right now rather than only that the token was valid when it was issued.

License

MIT