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:
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.
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)}
]
| Option | Default | Meaning |
|---|---|---|
name | SignalBoard.Oban | Unique atom naming this supervised integration and its Logger handler |
oban_name | Oban | Oban instance to observe; only one integration per instance on each node |
workers | :all | Allowed worker modules or strings, or all workers of this instance |
capture_logs | false | Forward logs emitted in an active selected job process |
log_level | :info | OTP Logger handler threshold; does not change the global threshold |
report | :final | Final failures, or :all to include intermediate exceptions |
enrich | nil | Optional function of arity 1 returning context/capture options |
filter_args | nil | Optional function of arity 2; basic secret redaction always follows it |
max_concurrency | 8 | Maximum 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:
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 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:
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}
)