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:
request_idfromPlug.RequestId/x-request-idtrace_idfromtraceparent/x-trace-id- request method, path, remote IP, and user agent
user.id,user.email- searchable attributes such as
agency_id,tenant_id, plan, or role - account and organization ids in event context and log metadata
- release, environment, runtime, and server name
- one structured request log per non-static request when
SignalBoard.PlugRequestLoggeris enabled
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:
feature.usedemail.sentemail.failedjob.startedjob.finishedjob.failedtenant.createdsubscription.changed
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:
feature.usedemail.sentemail.failedjob.startedjob.finishedjob.failedtenant.createdsubscription.changed
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}
)