Skip to main content

README.md

# 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:

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

## Quickstart

```elixir
{: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 |
| `:group_type_index` | — | Map of group-type → positional index `0..4` |
| `:enable_logging` | `false` | Verbose diagnostics via `Logger` |

Auth uses the `x-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. ALL `targeting_rules` must match `person_properties` (operators:
   `equals`, `not_equals`, `contains`, `gt`, `lt`) else `false`.
3. `bucket = murmurhash3("{key}.{distinct_id}") % 100`; ON iff `bucket < rollout_percentage`.
4. Multivariate: walk `variants` by cumulative rollout within the ON bucket.

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

## Architecture

The client holds two `GenServer`s: 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

```sh
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.
4. **Retry** — 503 then 200 succeeds after backoff (against a local stub).
5. **Survey round-trip + messaging dispatch + LLM trace** payload shapes.

## License

MIT