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