README.md

# risenexa_tracking

Elixir/Hex SDK for tracking user registrations and conversions with [Risenexa](https://app.risenexa.com).

Track when users sign up or convert to paying customers in your Phoenix application with a single function call.

## Installation

Add `risenexa_tracking` to your `mix.exs` dependencies:

```elixir
def deps do
  [
    {:risenexa_tracking, "~> 0.1.0"}
  ]
end
```

Then run:

```bash
mix deps.get
```

## Configuration

### Global Configuration (recommended)

Configure once in `config/config.exs` (or `config/runtime.exs` for runtime secrets):

```elixir
config :risenexa_tracking,
  api_key: "rxt_live_abc123",
  startup_slug: "my-phoenix-startup"
```

Then call module-level functions anywhere in your app:

```elixir
RisenexaTracking.track_registration(user_id: user.id)
RisenexaTracking.track_conversion(user_id: user.id)
```

### Per-Instance Configuration

For multi-startup use cases or dynamic credentials:

```elixir
client = RisenexaTracking.client(
  api_key: "rxt_live_abc123",
  startup_slug: "my-startup"
)

RisenexaTracking.track_registration(client, user_id: user.id)
RisenexaTracking.track_conversion(client, user_id: user.id)
```

Per-instance clients are fully independent — no shared state with global config or other instances.

## Configuration Options

| Option | Required | Default | Description |
|--------|----------|---------|-------------|
| `api_key` | Yes | — | Bearer token with `tracking:write` scope |
| `startup_slug` | Yes | — | Slug identifying the startup for all events |
| `base_url` | No | `"https://app.risenexa.com"` | API base URL (override for staging) |
| `timeout` | No | `2000` | Per-request timeout in milliseconds |
| `max_retries` | No | `3` | Max retry attempts (0 disables retries) |

## Usage

### Track User Registration

Call when a user creates an account:

```elixir
case RisenexaTracking.track_registration(user_id: to_string(user.id)) do
  {:ok, result} ->
    Logger.info("Tracked registration: #{result.event_id}")

  {:error, %{type: :configuration_error, message: msg}} ->
    Logger.error("SDK not configured: #{msg}")

  {:error, %{type: :authentication_error}} ->
    Logger.error("Invalid API key")

  {:error, error} ->
    Logger.warning("Tracking failed: #{inspect(error)}")
end
```

### Track User Conversion

Call when a user becomes a paying customer:

```elixir
{:ok, result} = RisenexaTracking.track_conversion(user_id: to_string(user.id))
```

### Low-Level `track/2`

For full control over all event fields:

```elixir
{:ok, result} = RisenexaTracking.track(client,
  event_type: "user_registered",
  user_id: "usr_123",
  event_id: "550e8400-e29b-41d4-a716-446655440000",  # optional — auto-generated if absent
  occurred_at: "2026-04-01T12:00:00Z",               # optional — server uses current time
  metadata: %{plan: "pro", source: "web"},            # optional — stored as JSONB
  action: "add"                                       # optional — "add" or "remove"
)
```

### Removing Events

Use `action: "remove"` to decrement counters (e.g., when a user cancels):

```elixir
# Decrement paying customer count on subscription cancellation
RisenexaTracking.track(client,
  event_type: "user_converted",
  user_id: to_string(user.id),
  action: "remove"
)
```

## Return Values

All functions return tagged tuples — no exceptions are raised.

### Success

```elixir
{:ok, %{
  status_code: 202,
  event_id: "550e8400-e29b-41d4-a716-446655440000",  # UUID sent in request
  body: %{"status" => "accepted"}
}}
```

### Errors

```elixir
# Missing configuration
{:error, %{type: :configuration_error, message: "api_key is required..."}}

# Invalid token
{:error, %{type: :authentication_error, status_code: 401, message: "Invalid token"}}

# Token lacks tracking:write scope
{:error, %{type: :authorization_error, status_code: 403, message: "Unauthorized scope"}}

# Wrong startup slug
{:error, %{type: :startup_not_found, status_code: 404, message: "startup not found"}}

# Invalid event data
{:error, %{
  type: :validation_error,
  status_code: 422,
  message: "Validation failed",
  errors: ["event_type must be user_registered or user_converted"]
}}

# Rate limited and retries exhausted
{:error, %{
  type: :rate_limited,
  status_code: 429,
  message: "Request failed after 3 retries",
  retry_after: 45  # seconds from Retry-After header
}}

# Server errors and retries exhausted
{:error, %{
  type: :max_retries_exceeded,
  last_status_code: 503,
  attempts: 4,
  message: "Request failed after 3 retries"
}}

# Transport failure (timeout, connection refused)
{:error, %{type: :connection_error, status_code: nil, message: "connection refused"}}
```

## Retry Behavior

The SDK automatically retries on transient errors with exponential backoff:

| Condition | Retryable | Behavior |
|-----------|-----------|----------|
| `429 Too Many Requests` | Yes | Honors `Retry-After` header; falls back to backoff |
| `500 Internal Server Error` | Yes | Exponential backoff |
| `502 Bad Gateway` | Yes | Exponential backoff |
| `503 Service Unavailable` | Yes | Exponential backoff |
| Timeout / Connection error | Yes | Exponential backoff |
| `401 Unauthorized` | No | Returns `{:error, ...}` immediately |
| `403 Forbidden` | No | Returns `{:error, ...}` immediately |
| `404 Not Found` | No | Returns `{:error, ...}` immediately |
| `422 Unprocessable Entity` | No | Returns `{:error, ...}` immediately |

### Backoff Formula

```
delay = min(1.0 * 2^attempt, 30.0) * (1 + jitter)  # jitter: ±20%
```

| Retry | Base Delay | Range |
|-------|------------|-------|
| 1st   | 1.0s       | [0.8s, 1.2s] |
| 2nd   | 2.0s       | [1.6s, 2.4s] |
| 3rd   | 4.0s       | [3.2s, 4.8s] |

### Idempotency

The SDK generates a UUID v4 `event_id` before the first HTTP attempt and reuses it on all retries. This ensures the Risenexa server counts each logical event exactly once, even if network conditions cause multiple delivery attempts.

## Phoenix Integration Example

```elixir
# lib/my_app/accounts.ex
defmodule MyApp.Accounts do
  def create_user(attrs) do
    with {:ok, user} <- %User{} |> User.changeset(attrs) |> Repo.insert() do
      # Track registration asynchronously to avoid blocking the request
      Task.start(fn ->
        case RisenexaTracking.track_registration(user_id: to_string(user.id)) do
          {:ok, _result} -> :ok
          {:error, error} -> Logger.warning("Risenexa tracking failed: #{inspect(error)}")
        end
      end)

      {:ok, user}
    end
  end
end
```

## License

MIT License. Copyright (c) 2026 Patrick Espake.

See [LICENSE](LICENSE) for full text.