Packages

Elixir SDK for SignalBoard error tracking, logs, and activity events

Current section

Files

Jump to
Raw

README.md

# SignalBoard Elixir SDK
Minimal Elixir SDK for sending events and structured logs to SignalBoard.
## Installation
For local development inside this workspace:
```elixir
def deps do
[
{:signalboard_sdk, path: "../sdk-elixir"}
]
end
```
From Hex:
```elixir
def deps do
[
{:signalboard_sdk, "~> 0.1.0"}
]
end
```
## Configuration
```bash
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:
```elixir
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:
```elixir
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:
```elixir
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_id` from `Plug.RequestId` / `x-request-id`
- `trace_id` from `traceparent` / `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.PlugRequestLogger` is 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`.
```elixir
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:
```elixir
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:
```elixir
# 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:
```elixir
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:
```elixir
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
```elixir
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.
```elixir
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.used`
- `email.sent`
- `email.failed`
- `job.started`
- `job.finished`
- `job.failed`
- `tenant.created`
- `subscription.changed`
For app-specific activity, keep names in `noun.verb` form:
```elixir
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
```elixir
def deps do
[
{:signalboard_sdk, path: "../sdk-elixir"}
]
end
```
### Configuración
```bash
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:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
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
```elixir
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.
```elixir
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.used`
- `email.sent`
- `email.failed`
- `job.started`
- `job.finished`
- `job.failed`
- `tenant.created`
- `subscription.changed`
Para eventos propios de cada app, usa nombres tipo `noun.verb`:
```elixir
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}
)
```