forge_ops_tracker (Elixir)

Elixir error reporting client for a ForgeOps instance. Requires Elixir 1.18+ and an OTP install with :inets/:ssl (bundled with every standard OTP install). Reports exceptions and process crashes anywhere in the BEAM VM, using OTP's own supervision and logging primitives rather than anything bolted on from outside.

Installation

def deps do
[
{:forge_ops_tracker, "~> 0.4.0"}
]
end

Configuration

Set a DSN (from a project's settings page in ForgeOps), either via the FORGE_OPS_DSN environment variable, config.exs, or an explicit init/1 call:

# config/config.exs
config :forge_ops_tracker,
dsn: "https://<api_key>@your-forgeops-host/api/v1/events",
environment: "production"
# or, at application startup (application.ex's own start/2, before the rest of your
# supervision tree starts, is the natural place):
ForgeOpsTracker.init(dsn: "...", environment: "production")
ForgeOpsTracker.install_handlers()

init/1 accepts the same keys as ForgeOpsTracker.Configuration's struct fields and raises ArgumentError for an unknown one, so a typo in a config key fails loudly at startup instead of silently being ignored.

What gets reported automatically, and what doesn't

install_handlers/0 attaches a standard :logger_handler (see ForgeOpsTracker.LoggerHandler's own module doc) that reports any process crash anywhere in the whole BEAM VM, with zero further wiring needed: a GenServer callback that raises, an unhandled message that trips handle_info's catch-all, a linked process crashing another, any of it. Erlang's own crash-reporting pipeline already forwards a structured {reason, stacktrace} for every abnormal process exit into :logger, because that's simply how OTP's "let it crash" supervision model already works. Attaching a handler observes that existing stream rather than building a new one. An ordinary Logger.error("something went wrong") call from application code (no crash attached) is deliberately left alone: reporting every error-level log line, not just real crashes, would be far noisier than this client should be by default.

For an exception you've already rescued yourself and want to report explicitly (and typically re-raise):

try do
charge_card(order)
rescue
e ->
ForgeOpsTracker.capture_exception(e, __STACKTRACE__, %{order_id: order.id})
reraise e, __STACKTRACE__
end

stacktrace has to come from the actual rescue/catch site (__STACKTRACE__): an Elixir exception value carries no trace of its own, so the special form is the only place a stacktrace is available, and only for as long as nothing else has run since the rescue/catch.

Delivery runs through a supervised ForgeOpsTracker.DeliveryQueueGenServer: each push is bounded (Configuration.queue_size, drops and logs rather than blocking the caller once full), and the actual HTTP call for each delivery runs in its own short-lived Task rather than inline in the GenServer's own callback, so a slow or unreachable tracker never makes the queue itself unresponsive to new pushes. Every failure mode (network errors, timeouts, a malformed DSN) is caught and logged rather than propagated, so a broken tracker can never take down the host app.

Identifying users

ForgeOpsTracker.capture_exception(error, __STACKTRACE__, %{}, %{id: user.id, email: user.email})

Or set_user/1 to attach it for the rest of the current process (a Phoenix request's own connection process, a GenServer, an IEx session) rather than passing it to every capture_exception/4 call by hand:

ForgeOpsTracker.set_user(%{id: conn.assigns.current_user.id, email: conn.assigns.current_user.email})

Stored in the process dictionary, the direct Elixir analog to Thread.current in gems/forge_ops_tracker's own equivalent: each Phoenix request already runs in its own process, so this is naturally request-scoped with no extra cleanup needed. id/email/username are all independently optional; call set_user/1 with no arguments (or an empty map) to clear whatever was set. Shows up on an issue's own detail page, and as its own affected-users count alongside the regular event count. Also attached to a process crash ForgeOpsTracker.LoggerHandler reports automatically (see "What gets reported automatically" above): confirmed directly that :logger dispatches a crash report synchronously, in the crashing process's own context, so whatever set_user/1 last set there is exactly what a crash report for that same process picks up.

Phoenix apps get this automatically: add ForgeOpsTracker.Integrations.Phoenix.UserContextPlug to your router's pipeline, after whatever plug sets conn.assigns[:current_user]:

pipeline :browser do
...
plug :fetch_current_user
plug ForgeOpsTracker.Integrations.Phoenix.UserContextPlug
end

conn.assigns[:current_user] is the de facto convention across Phoenix's own generated auth (mix phx.gen.auth), Pow, and most hand-rolled Guardian setups alike, since Phoenix itself (unlike Rails/Devise) has no single dominant auth library to depend on directly. id and email are read as plain optional struct/map fields; username falls back to name if username itself isn't present (phx.gen.auth's own generated schema has neither by default, only email). A no-op when conn.assigns[:current_user] is absent, or isn't a struct/map at all. Composes with the manual API above rather than replacing it: call set_user/1 yourself afterward (e.g. for a custom auth setup this can't detect, or to override what was auto-detected) and it wins for the rest of that process.

in_app backtrace frames

A frame is marked in_app when its module name (via inspect/1, which strips the Elixir. prefix every Erlang-level module atom carries) starts with Configuration.app_module_prefix, e.g. "MyApp" for an app whose modules are all namespaced under MyApp.*. Unset by default (no frame is marked in_app), since a compiled BEAM release carries no reliable filesystem convention to infer an application's own module prefix automatically.

Source context

By default, each in_app backtrace frame (never a third-party dependency) is captured along with the 5 lines of source on either side of the culprit line, read straight off disk at raise-time, so an issue's detail page can show the actual code that broke, not just a file:line reference. This never applies to a frame that isn't in_app (see above), and it fails silently (no context, not an error) for any file that can't be read for whatever reason: deleted, permission denied, or simply not present in this deployment, e.g. a release built without its own .ex sources bundled.

This is a real, deliberate exception to "off by default is safer": literal source code is being transmitted, not just a reference to it, and the real protection here is not this flag. Every project on ForgeOps has its own setting (on by default, off durably and immediately once an org owner turns it off, regardless of what any individual app's own capture_source_context is still set to) that governs whether the server will ever actually store what an SDK sends, see the in-app help docs. Use this option if you'd rather this client never even attempt the disk read in the first place:

ForgeOpsTracker.init(capture_source_context: false)

PII scrubbing

The message, backtrace, and any context you attach are scanned for likely personal data (email addresses, formatted SSNs/credit cards, known API key/token formats, and anything under a suspiciously-named key like password, api_key, or ssn) and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival regardless, so this is a second, earlier layer, not the only one. Elixir's Regex is full PCRE (via Erlang's :re), so every pattern (including \b word boundaries) is supported as written. The user attached via the fourth capture_exception/4 argument or set_user/1 above is a deliberate exception: it's never scrubbed, since redacting it would defeat the whole point of identifying users in the first place.

To disable it:

ForgeOpsTracker.init(scrub_pii: false)

Performance monitoring

Times requests, database queries, and background jobs, each its own kind ("controller"/"query"/"job") on the same aggregate performance dataset, so "slowest queries" and "slowest jobs" are just a filtered version of the same widget builder "slowest transactions" already uses. Unlike error reporting, this needs an explicit attach call per integration, since which of Phoenix/Ecto/Oban are actually in use varies by app:

# application.ex's own start/2, before the rest of your supervision tree starts:
ForgeOpsTracker.Integrations.Phoenix.attach()
ForgeOpsTracker.Integrations.Ecto.attach(MyApp.Repo)
ForgeOpsTracker.Integrations.Oban.attach()

Buckets flush as a small periodic aggregate report every performance_flush_interval (60 seconds by default) rather than one network call per event:

ForgeOpsTracker.init(
track_performance: false, # opt out entirely
performance_flush_interval: 30_000 # milliseconds; default 60_000
)

Requires a ForgeOps plan that includes performance monitoring; on a plan that doesn't, the periodic reports are simply rejected server-side and dropped, exactly like any other delivery failure.

Requires the optional :telemetry dependency (see Dependencies below); a host app using Phoenix, Ecto, or Oban already carries it transitively, since all three depend on it themselves.

Dependencies

No required Hex dependencies. HTTP delivery uses :httpc/:ssl (bundled with every OTP install, not a Hex package, with ssl: [verify: :verify_peer, cacerts: :public_key.cacerts_get()] for proper TLS certificate verification); JSON encoding uses Elixir's own JSON module, part of the standard library since 1.18 (this SDK's real version floor, not an arbitrary pin); PII scrubbing uses Elixir's built-in Regex. Two optional dependencies: :telemetry, needed only by ForgeOpsTracker.Integrations.Phoenix/Ecto/Oban (see Performance monitoring above), and :plug, needed only by ForgeOpsTracker.Integrations.Phoenix.UserContextPlug (see Identifying users above), since it's a real Plug module compiled directly against Plug.Conn/the Plug behaviour. A host app using Phoenix already carries both transitively; nothing else here needs a dependency the standard library doesn't already cover.

Running the tests

cd sdks/elixir
mix deps.get # fetches :telemetry (needed by the Integrations.* tests) and the dev-only ex_doc
mix test
mix format --check-formatted
mix compile --warnings-as-errors

Client/DeliveryQueue/Reporter/LoggerHandler tests run against a real local HTTP server (test/support/test_http_server.ex, a small hand-rolled :gen_tcp-based server) rather than a mock, so delivery is verified against something real rather than an assumption about how the HTTP client behaves.