SignalBoard Elixir SDK

Minimal Elixir SDK for sending events and structured logs to SignalBoard.

Installation

For local development inside this workspace:

def deps do
[
{:signalboard_sdk, path: "../sdk-elixir"}
]
end

From Hex:

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

Configuration

export SIGNALBOARD_DSN="https://sbp_live_xxx@signalboard.deployado.com"
export SIGNALBOARD_ENV="production"
export SIGNALBOARD_RELEASE="2026.05.13-1"

For Phoenix applications, prefer runtime configuration:

config :signalboard_sdk,
dsn: System.fetch_env!("SIGNALBOARD_DSN"),
environment: System.get_env("SIGNALBOARD_ENV", "production"),
release: System.get_env("SIGNALBOARD_RELEASE")

Phoenix / InsuranceBoard setup

In your Phoenix Endpoint:

defmodule InsuranceBoardWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :insurance_board
use SignalBoard.PlugCapture,
user: &InsuranceBoardWeb.SignalBoardContext.user/1,
attributes: &InsuranceBoardWeb.SignalBoardContext.attributes/1,
account_id: &InsuranceBoardWeb.SignalBoardContext.account_id/1,
organization_id: &InsuranceBoardWeb.SignalBoardContext.organization_id/1
plug Plug.RequestId
plug SignalBoard.PlugContext,
user: &InsuranceBoardWeb.SignalBoardContext.user/1,
attributes: &InsuranceBoardWeb.SignalBoardContext.attributes/1,
account_id: &InsuranceBoardWeb.SignalBoardContext.account_id/1,
organization_id: &InsuranceBoardWeb.SignalBoardContext.organization_id/1
plug SignalBoard.PlugRequestLogger,
user: &InsuranceBoardWeb.SignalBoardContext.user/1,
attributes: &InsuranceBoardWeb.SignalBoardContext.attributes/1,
account_id: &InsuranceBoardWeb.SignalBoardContext.account_id/1,
organization_id: &InsuranceBoardWeb.SignalBoardContext.organization_id/1
plug InsuranceBoardWeb.Router
end

Example extractor module:

defmodule InsuranceBoardWeb.SignalBoardContext do
def user(conn) do
case conn.assigns[:current_user] do
nil -> nil
user -> %{id: user.id, email: user.email}
end
end
def account_id(conn), do: conn.assigns[:current_account] && conn.assigns.current_account.id
def organization_id(conn), do: conn.assigns[:current_organization] && conn.assigns.current_organization.id
def attributes(conn) do
case conn.assigns[:current_user] do
%{agency_id: agency_id, role: role} when not is_nil(agency_id) ->
%{agency_id: agency_id, tenant_id: agency_id, user_role: role}
_user ->
%{}
end
end
end

The Phoenix integration automatically attaches:

PlugCapture ignores exceptions that Plug maps to a 4xx status (such as Phoenix.Router.NoRouteError) unless you pass capture_client_errors: true, and skips any module listed in excluded_exceptions: (per plug) or in config :signalboard_sdk, excluded_exceptions: [Postgrex.Error] (global, shared with SignalBoard.LoggerHandler).

Query strings are excluded by default to avoid leaking sensitive values. Pass include_query_string: true to SignalBoard.PlugContext only when query params are safe for your app.

Use attributes for low-cardinality fields you want to search and facet on, for example agency_id=123, tenant_id=123, plan=pro, or role=admin. Use context and log metadata for diagnostic payloads that are useful in details but are not primary search dimensions.

Delivery and batching

log/2, activity/2 and the track_* helpers are buffered: they return {:ok, :buffered} immediately and SignalBoard.Buffer ships batches in the background (every 50 items or 1 second, through POST /api/v1/batch). capture_exception/3 and capture_message/2 post synchronously and return the server response with the issue_id.

config :signalboard_sdk,
delivery: :buffered, # or :sync to post every call immediately
batch_size: 50,
flush_interval: 1_000,
max_queue_size: 5_000
SignalBoard.SDK.log("hello", delivery: :sync) # per-call override
SignalBoard.SDK.flush() # drain before shutdown / in tests

When the queue is full new items are dropped (SignalBoard.Buffer.stats/0 reports the count); delivery never blocks or raises in the caller.

Oban

SignalBoard.ObanReporter attaches to Oban telemetry and reports job.finished (with duration_ms), job.failed, and captures the exception with worker, queue, attempt and args:

SignalBoard.ObanReporter.attach(
tenant_arg: "institution_id", # job arg reported as tenant_id
started: false # set true to also track job.started
)

Push health checks

Services without a public /health endpoint (workers, schedulers) can report their own health. The check is created on the first report and goes down when it misses two reporting intervals or reports :down itself:

# e.g. from a scheduled Oban job every minute
SignalBoard.SDK.report_health(:billing_worker, :up, interval_seconds: 60)
SignalBoard.SDK.report_health(:billing_worker, :down, message: "queue backlog > 1000")

Always delivered synchronously; needs an API key with the health:write scope.

Metrics

Push numeric metrics and alert on them from SignalBoard → Alerts (metric rules). Gauges are levels, counters are increments; both are buffered:

SignalBoard.SDK.gauge("oban.queue.default.available", 12, tags: %{queue: "default"})
SignalBoard.SDK.increment("emails.sent")
SignalBoard.SDK.increment("whatsapp.messages", 3, tags: %{template: "reminder"})

Logger handler

SignalBoard.PlugCapture only sees exceptions raised inside a Plug request. Crashes in LiveView processes, GenServers, Tasks, or Oban workers, and explicit Logger.error/1 calls, are forwarded by the :logger handler. Attach it once in Application.start/2, before starting the supervision tree:

SignalBoard.LoggerHandler.attach(
level: :error,
metadata: [:request_id, :institution_id],
excluded_exceptions: [Postgrex.Error, DBConnection.ConnectionError]
)

Crash reports (crash_reason metadata) are sent as exceptions with their stacktrace; other messages at or above level are sent as message events. Logs from the :cowboy and :bandit domains are skipped by default so request crashes already captured by SignalBoard.PlugCapture are not reported twice. Delivery runs in a separate process (async: true) and never raises, so a SignalBoard outage cannot affect logging.

Options: level, capture_log_messages, metadata (list or :all), excluded_domains, excluded_exceptions, tags, async, plus dsn, environment, release, and transport overrides.

Usage

SignalBoard.SDK.capture_message("Payment failed", level: "error")
try do
risky_operation()
rescue
exception ->
SignalBoard.SDK.capture_exception(exception, __STACKTRACE__,
tags: %{"job" => "billing"},
context: %{"invoice_id" => "inv_123"}
)
reraise exception, __STACKTRACE__
end
SignalBoard.SDK.log("Payment intent created",
level: "info",
logger: "MyApp.Payments",
request_id: "req_123",
attributes: %{agency_id: "agency_123", plan: "pro"},
metadata: %{"amount" => 1999, "currency" => "usd"}
)
SignalBoard.SDK.add_breadcrumb("policy quoted",
category: "policy",
metadata: %{policy_id: "pol_123"}
)
SignalBoard.SDK.set_context(%{context: %{carrier: "acme"}})
SignalBoard.SDK.set_attributes(%{agency_id: "agency_123", tenant_id: "agency_123"})

Activity conventions

Use these helpers for the common business and operational events every SaaS should report. They all call SignalBoard.SDK.activity/2 under the hood, so DSN, environment, release, request context, tenant, user, and fail-silent behavior work the same way.

SignalBoard.SDK.track_feature_used("policy.quote",
tenant: %{id: agency.id, name: agency.name},
attributes: %{policy_type: "auto"}
)
SignalBoard.SDK.track_email_sent(
tenant: %{id: agency.id},
template: "policy_renewal",
provider: "resend",
message_id: message_id,
recipient_email: customer.email,
duration_ms: duration_ms
)
SignalBoard.SDK.track_email_failed(reason,
tenant: %{id: agency.id},
template: "policy_renewal",
provider: "resend",
recipient_email: customer.email
)
SignalBoard.SDK.track_job_started("renewal_reminders", queue: "default")
SignalBoard.SDK.track_job_finished("renewal_reminders", queue: "default", duration_ms: 842)
SignalBoard.SDK.track_job_failed("renewal_reminders", reason, queue: "default", attempt: 2)
SignalBoard.SDK.track_tenant_created(%{id: agency.id, name: agency.name}, plan: agency.plan)
SignalBoard.SDK.track_subscription_changed(
tenant: %{id: agency.id},
from_plan: "basic",
to_plan: "pro",
provider: "stripe"
)

Recommended event names:

For app-specific activity, keep names in noun.verb form:

SignalBoard.SDK.activity("policy.issued",
tenant: %{id: agency.id, name: agency.name},
user: %{id: user.id, email: user.email},
attributes: %{policy_id: policy.id, carrier: policy.carrier},
properties: %{premium_cents: policy.premium_cents}
)

Español

SDK mínimo de Elixir para enviar eventos y logs estructurados a SignalBoard.

Instalación local

def deps do
[
{:signalboard_sdk, path: "../sdk-elixir"}
]
end

Configuración

export SIGNALBOARD_DSN="https://sbp_live_xxx@signalboard.deployado.com"
export SIGNALBOARD_ENV="production"
export SIGNALBOARD_RELEASE="2026.05.13-1"

Para Phoenix, configura el SDK en runtime:

config :signalboard_sdk,
dsn: System.fetch_env!("SIGNALBOARD_DSN"),
environment: System.get_env("SIGNALBOARD_ENV", "production"),
release: System.get_env("SIGNALBOARD_RELEASE")

Setup Phoenix / InsuranceBoard

En el Endpoint:

defmodule InsuranceBoardWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :insurance_board
use SignalBoard.PlugCapture,
user: &InsuranceBoardWeb.SignalBoardContext.user/1,
attributes: &InsuranceBoardWeb.SignalBoardContext.attributes/1,
account_id: &InsuranceBoardWeb.SignalBoardContext.account_id/1,
organization_id: &InsuranceBoardWeb.SignalBoardContext.organization_id/1
plug Plug.RequestId
plug SignalBoard.PlugContext,
user: &InsuranceBoardWeb.SignalBoardContext.user/1,
attributes: &InsuranceBoardWeb.SignalBoardContext.attributes/1,
account_id: &InsuranceBoardWeb.SignalBoardContext.account_id/1,
organization_id: &InsuranceBoardWeb.SignalBoardContext.organization_id/1
plug SignalBoard.PlugRequestLogger,
user: &InsuranceBoardWeb.SignalBoardContext.user/1,
attributes: &InsuranceBoardWeb.SignalBoardContext.attributes/1,
account_id: &InsuranceBoardWeb.SignalBoardContext.account_id/1,
organization_id: &InsuranceBoardWeb.SignalBoardContext.organization_id/1
plug InsuranceBoardWeb.Router
end

Extractor recomendado:

defmodule InsuranceBoardWeb.SignalBoardContext do
def user(conn) do
case conn.assigns[:current_user] do
nil -> nil
user -> %{id: user.id, email: user.email}
end
end
def account_id(conn), do: conn.assigns[:current_account] && conn.assigns.current_account.id
def organization_id(conn), do: conn.assigns[:current_organization] && conn.assigns.current_organization.id
def attributes(conn) do
case conn.assigns[:current_user] do
%{agency_id: agency_id, role: role} when not is_nil(agency_id) ->
%{agency_id: agency_id, tenant_id: agency_id, user_role: role}
_user ->
%{}
end
end
end

Esto adjunta automáticamente request_id, trace_id, usuario, atributos buscables, cuenta, organización, release, environment, runtime y metadata básica del request. El query string se excluye por defecto para evitar filtrar datos sensibles. SignalBoard.PlugRequestLogger envía un log estructurado por request no estático.

Usa attributes para dimensiones que quieras buscar o convertir en facets, por ejemplo agency_id=123, tenant_id=123, plan=pro o role=admin. Usa context y metadata para payloads de diagnóstico que deben verse en el detalle, pero no son la dimensión principal de búsqueda.

Entrega y batching

log/2, activity/2 y los helpers track_* se bufferizan: devuelven {:ok, :buffered} de inmediato y SignalBoard.Buffer envía lotes en segundo plano (cada 50 items o 1 segundo, vía POST /api/v1/batch). capture_exception/3 y capture_message/2 envían síncrono y devuelven la respuesta del server con el issue_id. Usa delivery: :sync | :buffered por llamada o en config, y SignalBoard.SDK.flush() para vaciar el buffer.

Oban

SignalBoard.ObanReporter.attach(tenant_arg: "institution_id") reporta job.finished, job.failed y captura la excepción de cada job fallido.

Logger handler

SignalBoard.PlugCapture solo ve excepciones dentro de un request de Plug. Los crashes en procesos LiveView, GenServers, Tasks u Oban workers, y las llamadas explícitas a Logger.error/1, se reenvían con el handler de :logger. Actívalo una vez en Application.start/2, antes de arrancar el árbol de supervisión:

SignalBoard.LoggerHandler.attach(
level: :error,
metadata: [:request_id, :institution_id],
excluded_exceptions: [Postgrex.Error, DBConnection.ConnectionError]
)

Los crash reports se envían como excepciones con stacktrace; el resto de mensajes con nivel ≥ level se envían como eventos de mensaje. Los dominios :cowboy y :bandit se omiten por defecto para no duplicar lo que ya captura SignalBoard.PlugCapture. El envío corre en otro proceso (async: true) y nunca lanza excepciones.

Uso

SignalBoard.SDK.capture_message("Falló el pago", level: "error")
try do
operacion_riesgosa()
rescue
exception ->
SignalBoard.SDK.capture_exception(exception, __STACKTRACE__,
tags: %{"job" => "billing"},
context: %{"invoice_id" => "inv_123"}
)
reraise exception, __STACKTRACE__
end
SignalBoard.SDK.log("Payment intent creado",
level: "info",
logger: "MyApp.Payments",
request_id: "req_123",
attributes: %{agency_id: "agency_123", plan: "pro"},
metadata: %{"amount" => 1999, "currency" => "usd"}
)
SignalBoard.SDK.add_breadcrumb("cotización generada",
category: "policy",
metadata: %{policy_id: "pol_123"}
)
SignalBoard.SDK.set_attributes(%{agency_id: "agency_123", tenant_id: "agency_123"})

Convenciones de actividad

Usa estos helpers para los eventos de negocio y operación que conviene reportar en todos los SaaS. Todos usan SignalBoard.SDK.activity/2 internamente, así que mantienen DSN, environment, release, contexto del request, tenant, usuario y el comportamiento fail-silent.

SignalBoard.SDK.track_feature_used("policy.quote",
tenant: %{id: agency.id, name: agency.name},
attributes: %{policy_type: "auto"}
)
SignalBoard.SDK.track_email_sent(
tenant: %{id: agency.id},
template: "policy_renewal",
provider: "resend",
message_id: message_id,
recipient_email: customer.email,
duration_ms: duration_ms
)
SignalBoard.SDK.track_email_failed(reason,
tenant: %{id: agency.id},
template: "policy_renewal",
provider: "resend",
recipient_email: customer.email
)
SignalBoard.SDK.track_job_started("renewal_reminders", queue: "default")
SignalBoard.SDK.track_job_finished("renewal_reminders", queue: "default", duration_ms: 842)
SignalBoard.SDK.track_job_failed("renewal_reminders", reason, queue: "default", attempt: 2)
SignalBoard.SDK.track_tenant_created(%{id: agency.id, name: agency.name}, plan: agency.plan)
SignalBoard.SDK.track_subscription_changed(
tenant: %{id: agency.id},
from_plan: "basic",
to_plan: "pro",
provider: "stripe"
)

Nombres recomendados:

Para eventos propios de cada app, usa nombres tipo noun.verb:

SignalBoard.SDK.activity("policy.issued",
tenant: %{id: agency.id, name: agency.name},
user: %{id: user.id, email: user.email},
attributes: %{policy_id: policy.id, carrier: policy.carrier},
properties: %{premium_cents: policy.premium_cents}
)