Elixir error tracking
Know when your Elixir app breaks, before your users do
ForgeOps connects the errors your Elixir app reports to the deploys, traces, and changes around them, so when production breaks, the incident already says who's affected, what changed, and where to look.
A logger handler reports any process crash anywhere in the whole BEAM VM, with zero further wiring needed. Running on ForgeOps' own dedicated infrastructure, so nothing about a Elixir app's errors or its users' data gets routed through a third-party error-tracking vendor. See an incident investigated →
Install
# mix.exs
{:forge_ops_tracker, "~> 0.15"}
ForgeOpsTracker.init(dsn: "https://<api_key>@getforgeops.net/api/v1/events")
ForgeOpsTracker.install_handlers()
A logger handler reports any process crash anywhere in the whole BEAM VM, with zero further wiring needed.
Every client reports the same shape
Wherever it comes from, an event arrives with an exception class, a message, and a backtrace, plus whatever environment/release/server context that client can gather on its own. ForgeOps fingerprints and groups on the first three, so a Elixir app's issues list works exactly the same way every other language's does: report the same bug a thousand times and it's still one row, not a thousand.
No agent, no sidecar: it's a plain HTTPS POST to ForgeOps' own ingestion API, so nothing about a Elixir app's traffic gets routed through a third-party pipeline first.
Works with your framework
The Elixir client hooks in at the framework level, not by wrapping individual calls, so nothing about how you already write Elixir code has to change.
Any OTP process
Attaches a :logger_handler to Erlang's own crash-reporting pipeline, covering a GenServer callback, a linked process, or anything else that crashes anywhere in the VM.
Explicitly rescued exceptions
capture_exception reports at the rescue site, typically right before re-raising with the same __STACKTRACE__.
Scripts and escripts
mix run, elixir scripts, escripts and a normal VM stop send whatever's still queued first (waiting at most 5 seconds); call ForgeOpsTracker.flush/1 before System.halt/1.
What it looks like
The issue list
Every Elixir issue reported into a project shows up here: title, status, assignee, event count, and when it was last seen, the same columns as any other language's project.
The issue detail
Every occurrence keeps its own timestamp, environment, release, and server, plus the backtrace exactly as Elixir reported it, expandable per occurrence rather than flattened into one generic stack.
Alerting
A new Elixir issue, a regression, or a spike past a threshold you set can reach Slack, Microsoft Teams, email, PagerDuty, Opsgenie, or a generic webhook, configured per project, per trigger.
Heartbeats
For the failure mode error tracking can't see on its own: a Elixir cron job or recurring task that's supposed to run but silently stopped. Each heartbeat gets its own ping URL and its own grace period before it alerts.
Background jobs
The :logger_handler attached above already covers a GenServer callback that raises, a linked process crashing another, or anything else that crashes anywhere in the whole BEAM VM, with zero further wiring needed.
Oban is the one common exception: it catches a failing job's exception itself, wrapped in its own Oban.CrashError, specifically so one job never crashes its queue's supervisor, so it never becomes an actual process crash the handler above would see. One line at startup covers it instead, reporting a job once its retries are genuinely exhausted (or it explicitly gives up) the same way any other exception already is, with the worker's own name attached as context:
ForgeOpsTracker.Integrations.Oban.attach()
Performance monitoring
Times requests, database queries, and background jobs, each its own kind on the same dashboard widgets every other language's performance data feeds, so "slowest queries" and "slowest jobs" are just a filtered view of "slowest transactions." Unlike error reporting above, this needs its own explicit attach call per integration, since which of Phoenix/Ecto/Oban are actually in use varies by app. Bucketed by transaction (a Phoenix route's own matched pattern, e.g. "GET /users/:id", not the literal path; a query's own table, e.g. "SELECT users"; a job's own worker module name) and flushed as a small periodic aggregate report every 60 seconds, the same delivery philosophy as error reporting above. Requires a plan that includes performance monitoring; on a plan that doesn't, the periodic reports are accepted but not recorded (the response says why), so a working setup never looks broken. Each report also carries a small latency histogram (version 0.9.0 and later), so ForgeOps shows an approximate p50/p95/p99 per transaction, not just an average, accurate to the width of the latency bucket a duration falls into.
ForgeOpsTracker.Integrations.Phoenix.attach() ForgeOpsTracker.Integrations.Ecto.attach(MyApp.Repo) ForgeOpsTracker.Integrations.Oban.attach() ForgeOpsTracker.init( track_performance: false, # opt out entirely performance_flush_interval: 30_000 # milliseconds; default 60_000 )
Distributed tracing
With the Phoenix, Oban, and Ecto integrations attached, each request or job becomes a trace, and every Ecto query nests in as a database span (named like "SELECT users", never the SQL). Only a slow request's trace is ever sent: whether it crossed the threshold (one second by default, configurable) is decided entirely inside your process, so a fast request costs nothing over the wire. Wrap anything else by hand. Requires a plan that includes distributed tracing.
order = ForgeOpsTracker.span("charge card", "service", %{order_id: id}, fn -> charge(id) end)
rates = ForgeOpsTracker.span("fetch rates", "http", fn -> Req.get!(url) end)
# Something you timed yourself (kind is one of controller/service/database/redis/http/job/other;
# started_at is a DateTime):
ForgeOpsTracker.record_span("SELECT orders", "database", started_at, duration_ms)
Errors now carry the request they happened in: its endpoint (POST /checkout) and its trace id. An issue names its affected endpoint, each occurrence links to its exact trace, and a request that fails is always traced, however fast. With the Phoenix integration, a request that arrives with the standard traceparent header continues the caller's trace on its own; pass it on by wrapping an outbound call in http_span, which records it as an http span and hands your function the headers to add, with any HTTP client. Once the projects are linked in ForgeOps an error shows the errors another project raised in the same request. Needs forge_ops_tracker 0.11.0.
response =
ForgeOpsTracker.http_span("POST", url, fn headers ->
Req.post!(url, json: order, headers: headers)
end)
ForgeOpsTracker.init(
dsn: "...",
trace_propagation_targets: ["api.example.com"] # only these hosts; nil (the default) means all
)
SQL in a request
When the request an issue failed in was traced, the issue opens with its slowest query: how long it took, its share of the request's time, how many queries the request ran, and a likely N+1 warning when the same statement ran five or more times. Every query the Ecto integration records already carries its SQL, masked so every string and number becomes a question mark and query params are never sent. Turn on explain_slow_queries and a slow SELECT on PostgreSQL gets its plan too, shown beside the query with any sequential scan over a large table pointed out; it runs plain EXPLAIN, never EXPLAIN ANALYZE, and only for a single plain SELECT, off the request on its own connection inside a read-only transaction with a two second timeout, at most once per statement every ten minutes. Needs forge_ops_tracker 0.13.0; plans need a plan that includes performance monitoring.
ForgeOpsTracker.Integrations.Ecto.attach(MyApp.Repo) ForgeOpsTracker.init( dsn: "...", explain_slow_queries: true, # default false; Ecto.Adapters.Postgres repos only explain_threshold_ms: 500 # milliseconds; default 500 )
What changed
A feature flag flip or a config edit can break things with no deploy at all. Record one with record_change and it shows under What changed on any issue that starts in the next two hours, and on the project's Changes page beside your deploys, labeled potentially relevant, never as the cause. Changes between deploys are detected for you: once per VM, the client sends the runtime and every loaded OTP application with its version, from its own Task so startup never waits, and a dependency upgrade shows up with nothing to call. Environment variable names (never values) are opt-in. Needs forge_ops_tracker 0.12.0, on a plan that includes change tracking.
ForgeOpsTracker.init(
dsn: System.get_env("FORGE_OPS_DSN"),
detect_changes: true, # the default; false sends no snapshot
track_env_var_names: true # default false; names only, never values
)
ForgeOpsTracker.record_change(:feature_flag, "gateway_retry_v2 turned on for 100% of checkouts",
details: %{flag: "gateway_retry_v2", from: "10%", to: "100%"},
actor: "priya@example.com"
)
Custom metrics and infrastructure monitoring
Track a business event you name yourself (a signup, a payment) or a reading from one of your own hosts, with one explicit call each; nothing is automatic. The value defaults to 1, so a bare call is a counter; pass a real amount for anything else (it can be negative, for a refund). A captured value is stored as its own row, so counts and sums you compute later are exact. Calls are buffered and flushed as one batch in the background, so they are safe to make inside a request, and flush_metrics sends whatever is buffered right now. The hostname of an infrastructure reading defaults to the configured server name. Requires a plan that includes custom metrics and infrastructure monitoring.
ForgeOpsTracker.capture_metric("signup") # value defaults to 1.0: a bare counter
ForgeOpsTracker.capture_metric("payment", 49.0) # a real magnitude; it may be negative (a refund)
ForgeOpsTracker.capture_infrastructure_metric("cpu", 0.42) # hostname defaults to server_name
ForgeOpsTracker.capture_infrastructure_metric("disk", 0.81, "db-1")
ForgeOpsTracker.flush_metrics() # optional: send right now
Database errors
When an error comes from a database call, the issue shows which stored procedure, table or view its SQL touched, so you know where to start looking. Postgrex.Error and MyXQL.Error carry the statement in a :query or :statement field, so it's read off them with nothing to add. The names are sent by default and are identifiers, never values. The SQL statement itself is opt-in, with every string and number replaced by a question mark before it leaves your app, and a project setting can stop ForgeOps storing the statement at all.
ForgeOpsTracker.init(capture_sql_statement: true) # default false # ForgeOpsTracker.init(capture_sql_objects: false) # default true; false stops even the names
Questions
Do I need to change how I already handle errors?
Almost never. Every client hooks the framework's own exception handling directly, the same way the install snippet above shows, so your own logs, error pages, and rescue blocks keep working exactly as they did before, and ForgeOps just also finds out about it. The one common exception is a background-job runner that doesn't forward through that same mechanism on its own; that needs one small hook added instead, not a change to anything already there.
Where does the data actually go?
Straight to ForgeOps' own ingestion API over plain HTTPS. No agent, no sidecar, and no third-party ingestion service in between, so nothing about your app's traffic or its users' data gets routed through anyone else's pipeline first.
What happens if the same bug fires a thousand times?
It's fingerprinted from its exception class, message, and backtrace, so a thousand reports of the same bug still show up as one issue with an event count of 1,000, not a thousand separate rows to dig through. And a runaway loop can't use up your monthly event quota: once one issue repeats faster than your plan's hourly limit (50 an hour on Free), further repeats are still counted, and still count toward spike alerts, but they aren't stored or charged to your quota.
Is anything scrubbed before it's stored?
Yes. Emails, credit card numbers, and known API key/token formats are redacted out of every event before it's even written, on every plan, with room for per-project custom field names on top of the defaults.
Try it with your own Elixir app
Free plan included, no credit card required. New organizations start with 14 days of Business.
Get started freeGet