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.5.0"}
]
end

Configuration

export SIGNALBOARD_DSN="https://sbp_live_xxx@signalboard.deployado.com"
export SIGNALBOARD_ENV="production"
export DEPLOYADO_RELEASE_VERSION="2026.05.13-1"
export DEPLOYADO_RELEASE_ID="deploy_abc123"
export SIGNALBOARD_RELEASE_SHA="a1b2c3d4"

For Phoenix applications, prefer runtime configuration:

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

The SDK resolves a display release from an explicit release: option or app configuration, then SIGNALBOARD_RELEASE, the resolved SHA, and finally DEPLOYADO_RELEASE_VERSION. It records available SHA, version, and deployment ID separately in payload metadata. SHA resolution checks explicit release_sha:/app configuration, SIGNALBOARD_RELEASE_SHA, RENDER_GIT_COMMIT, then GITHUB_SHA; version and deployment ID similarly accept release_version:/deployment_id: before their Deployado variables.

Do not configure release from your application version as a fallback: an app version does not necessarily identify the deployed code, and a release version is not assumed to be a SHA. For a deliberate override, configure :release, :release_sha, :release_version, or :deployment_id under :signalboard_sdk, or pass the matching capture option.

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.

Optional Oban diagnostics

Add the integration before Oban in your application's supervision tree. Oban is not a dependency of the SDK; nothing is installed unless you add this child. The integration uses the SDK's DSN, environment, and release resolution. Without a DSN it does no capture work and does not change job context.

children = [
{SignalBoard.Oban,
workers: [MyApp.RoutingWorker],
capture_logs: true,
enrich: &MyApp.JobDiagnostics.enrich/1,
filter_args: &MyApp.JobDiagnostics.filter_args/2},
{Oban, Application.fetch_env!(:my_app, Oban)}
]
OptionDefaultMeaning
nameSignalBoard.ObanUnique atom naming this supervised integration and its Logger handler
oban_nameObanOban instance to observe; only one integration per instance on each node
workers:allAllowed worker modules or strings, or all workers of this instance
capture_logsfalseForward logs emitted in an active selected job process
log_level:infoOTP Logger handler threshold; does not change the global threshold
report:finalFinal failures, or :all to include intermediate exceptions
enrichnilOptional function of arity 1 returning context/capture options
filter_argsnilOptional function of arity 2; basic secret redaction always follows it
max_concurrency8Maximum simultaneous delivery slots; no pending delivery queue

Standard SDK options such as dsn, environment, release options, and transport are accepted at integration startup. Invalid options fail startup without partially installing handlers. Shutting down or restarting the integration removes its own handlers and does not replace unrelated handlers.

Attribution and declared causes

enrich receives %{phase: :start | :stop | :exception, job: job, reason: reason_or_nil, state: state_or_nil}. The normalized job includes id, worker, queue, args, attempt, and max_attempts; modern metadata.job fields take precedence over historical top-level fields. Return a map or keyword list containing user, tags, attributes, context, and/or fingerprint. These callbacks must be fast and must not perform I/O.

defmodule MyApp.JobDiagnostics do
def enrich(%{job: job, reason: reason}) do
base = %{attributes: %{
tenant_id: job.args["tenant_id"],
provider_message_id: job.args["provider_message_id"]
}}
# Application-defined domain reasons, not guesses from exception text.
case reason do
%{__struct__: Oban.PerformError, reason: {:error, {:routing, cause}}}
when cause in [:internal_message, :external_message] ->
Map.put(base, :fingerprint, ["oban", job.worker, "route", Atom.to_string(cause)])
_ -> base
end
end
def filter_args(_worker, args), do: Map.drop(args, ["customer_email"])
end
# Inside perform/1, when the cause is known:
SignalBoard.SDK.set_context(%{
fingerprint: ["oban", "MyApp.RoutingWorker", "route", "external_message"]
})
SignalBoard.SDK.set_attributes(%{routing_result: "external_message"})
Logger.info("routing decided", routing_result: "external_message")

Fingerprint precedence is terminal enrichment, then worker declaration, then start enrichment. A terminal callback should return a fingerprint only when it intends to replace an earlier declaration. Use stable domain causes; never include release/SHA, job ID, attempt, tenant ID, or provider message ID in the fingerprint. Without a declaration, existing SDK/server grouping is unchanged; a generic :not_found cannot distinguish two domain causes automatically.

Use attributes for diagnostic IDs and attribution. For a platform operation, set attributes.scope = "platform" and omit tenant if none exists. Do not invent a human user. The SDK adds generic job attributes and worker/queue tags, and includes filtered job fields in event context.

Logs, correlation, and delivery limits

Info logs go to /api/v1/logs and never create issues. Final exceptions and explicit discards with a reason go to /api/v1/events; success, snooze and cancellation do not. Missing retry counts cause the exception to be reported with context.finality = "unknown" unless discard already proves finality.

Both the global Logger filter and log_level must accept a message. For example, configure config :logger, level: :info in the consumer if needed; the SDK never changes it or revives messages removed at compile time. Promote important diagnostics to Logger.info or above deliberately. Only location metadata (file, line, mfa, domain) and public SDK context are forwarded; put searchable domain values in SignalBoard.SDK.set_attributes/1, not merely arbitrary Logger metadata. OTP notice becomes info; critical, alert, and emergency become fatal.

Each attempt gets a deterministic 32-character trace from Oban instance, job ID and attempt. Another attempt gets another trace. Incomplete ID/attempt metadata produces no trace and is marked context.correlation = "incomplete_metadata". The integration fixes trace and removes request IDs after context merging, so worker/enrichment overrides cannot accidentally mix attempts. Store parent correlation only as context.parent_request_id or context.parent_trace_id. Job fields and filtered args are reserved. Logs retain job identity and declared attributes but do not automatically duplicate context.args.

Start clears inherited user/account/request/tags; a matching terminal event restores the previous process context. A timeout or crash reported from another process reconstructs the base context and runs terminal enrichment without using that reporting process's ambient context. Worker-only in-memory context and logs can be lost on abrupt death; use durable job data for causes or attribution that must survive. Child processes do not inherit the capture scope.

Payloads are built in the emitting process before asynchronous delivery. Each delivery has a fixed five-second deadline, no extra retries (including the default HTTP transport), and no durable buffering. At saturation new payloads are dropped immediately. A drop emits local telemetry [:signalboard, :delivery, :dropped] with only %{cause: cause, type: :event | :log}; causes include :saturated, :unavailable, :timeout, :transport_error, :filter_error, and :build_error. Transport logs are excluded from recapture. Delivery is best effort: network failures, saturation, shutdown, or process death can lose data. Related Logs shows received and retained logs for the event's attempt, not a guaranteed complete transcript.

Privacy and adoption

The basic redactor recursively masks case-insensitive keys password, password_confirmation, secret, token, access_token, refresh_token, api_key, authorization, cookie, set-cookie, and client_secret while preserving nested diagnostic IDs. The consumer's filter_args runs first. Filter failures drop the affected payload; unfiltered args are never used as a fallback. Enrichment failures retain base context with context.enrichment_error = true, without the callback's exception text. Structured Oban.PerformError.reason is redacted before rendering, without rendering its original message or the whole job. Arbitrary free-text log and exception messages may still contain secrets: consumers must avoid logging sensitive text and add their own domain filtering.

Before adopting this integration, remove the old Oban reporter for the same workers and any Logger-to-issue reporter that would duplicate its failures. Start with a small worker allowlist, verify one terminal failure and correlated info log, then widen capture after checking volume. New explicit fingerprints may start new issues; historical issues and missing historical logs/SHA are not rewritten. Also remove explicit application-version release fallbacks if you want the SDK's deployed-code release resolution.

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 activity reporter

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
)

Do not attach SignalBoard.ObanReporter alongside SignalBoard.Oban for the same workers: both capture failures and would create duplicate reports. Choose the supervised diagnostics integration above when you need per-attempt logs, filtered args and domain-defined fingerprints.

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"})
# A declared domain cause can group job failures consistently.
SignalBoard.SDK.set_context(%{fingerprint: ["oban", "BillingWorker", "card_declined"]})

For event captures, an explicit fingerprint: option takes precedence over a fingerprint declared in the current process context.

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 desde Hex

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

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 DEPLOYADO_RELEASE_VERSION="2026.05.13-1"
export DEPLOYADO_RELEASE_ID="deploy_abc123"
export SIGNALBOARD_RELEASE_SHA="a1b2c3d4"

Para Phoenix, configura el SDK en runtime:

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

El SDK resuelve el release visible desde release: explícito o configuración de la app, después SIGNALBOARD_RELEASE, el SHA resuelto y finalmente DEPLOYADO_RELEASE_VERSION. Conserva SHA, versión e ID de despliegue por separado; una versión no se interpreta como SHA. No use la versión de la app como fallback de release, porque no identifica necesariamente el código desplegado.

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}
)