risenexa_tracking

Elixir/Hex SDK for tracking user registrations and conversions with Risenexa.

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:

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

Then run:

mix deps.get

Configuration

Global Configuration (recommended)

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

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

Then call module-level functions anywhere in your app:

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:

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:

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:

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

Low-Level track/2

For full control over all event fields:

{: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):

# 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

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

Errors

# 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

# 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 for full text.