Current section
Files
Jump to
Current section
Files
lib/metrixwire.ex
defmodule MetrixWire do
@moduledoc """
Zero-config APM SDK for Elixir.
Add the dependency and set `METRIXWIRE_KEY` (or `config :metrixwire, api_key: ...`)
and it is **fully automatic** — every HTTP request becomes a trace, and every DB
query / outbound HTTP call within it becomes a span. There is no manual span API.
The SDK ships its own OTP application (`MetrixWire.Application`) which, on boot,
starts the transport GenServer and attaches every `:telemetry` handler. Phoenix,
Ecto, Finch and Tesla all emit telemetry, so a single handler set instruments the
whole app with zero code changes. Bare Plug apps add one line: `plug MetrixWire.Plug`.
Non-blocking: traces are batched in a GenServer and flushed off the request path
with a short timeout, and **all** transport errors are swallowed — instrumentation
must never throw into or block the host application.
"""
alias MetrixWire.{Config, Context, Trace, Transport}
@doc """
Enqueue a finished trace for delivery. Internal — used by instrumentation.
"""
def enqueue(%Trace{} = trace) do
Transport.enqueue(trace)
rescue
_ -> :ok
end
@doc "Whether the SDK is enabled (configured with an API key)."
def enabled?, do: Config.enabled?()
@doc "The configured ingest endpoint URL, or nil."
def endpoint, do: Config.get().endpoint
@doc "Whether span source-location capture is on."
def capture_source?, do: Config.get().capture_source
@doc """
Manually flush the queue (e.g. before a short-lived process exits).
"""
def flush, do: Transport.flush()
@doc """
Attach an exception to the active trace. Escape hatch mirroring the other SDKs —
NOT a manual span API; requests/queries are still captured automatically.
"""
def capture_exception(error, stacktrace \\ nil) do
case Context.current() do
%Trace{} = trace -> Context.put(Trace.capture_exception(trace, error, stacktrace))
_ -> :ok
end
:ok
rescue
_ -> :ok
end
# ── shared utilities used across the SDK ──────────────────────────────────
@doc "Monotonic milliseconds — safe for measuring durations (never goes back)."
def monotonic_ms, do: System.monotonic_time(:millisecond)
@doc ~S(ISO8601 UTC with millisecond precision, e.g. "2026-07-15T10:00:00.000Z".)
def iso8601(%DateTime{} = dt) do
dt
|> DateTime.truncate(:millisecond)
|> DateTime.to_iso8601()
|> normalize_iso()
rescue
_ -> iso8601(DateTime.utc_now())
end
def iso8601(_), do: iso8601(DateTime.utc_now())
@doc "ISO8601 string for `ms_ago` milliseconds before now (used for span start)."
def iso8601_ago(ms_ago) when is_integer(ms_ago) do
DateTime.utc_now()
|> DateTime.add(-ms_ago, :millisecond)
|> iso8601()
end
def iso8601_ago(_), do: iso8601(DateTime.utc_now())
# DateTime.to_iso8601/1 yields "...Z" for UTC in recent Elixir, but be defensive
# about "+00:00" too — the wire format wants a trailing "Z".
defp normalize_iso(str) do
str
|> String.replace_suffix("+00:00", "Z")
|> ensure_millis()
end
# Guarantee millisecond precision (".000") even if truncation dropped it.
defp ensure_millis(str) do
cond do
Regex.match?(~r/\.\d{3}Z$/, str) -> str
String.ends_with?(str, "Z") -> String.replace_suffix(str, "Z", ".000Z")
true -> str
end
end
@doc """
Build the exception meta map from an arbitrary error + optional stacktrace.
Keeps the top ~8 backtrace frames.
"""
def exception_meta(error, stacktrace \\ nil) do
%{
"type" => exception_type(error),
"message" => exception_message(error),
"stack" => format_stack(stacktrace)
}
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
rescue
_ -> nil
end
defp exception_type(error) when is_exception(error), do: inspect(error.__struct__)
defp exception_type(error) when is_atom(error), do: Atom.to_string(error)
defp exception_type(_), do: "RuntimeError"
defp exception_message(error) when is_exception(error) do
Exception.message(error)
rescue
_ -> inspect(error)
end
defp exception_message(error) when is_binary(error), do: error
defp exception_message(error), do: inspect(error)
defp format_stack(nil), do: nil
defp format_stack([]), do: nil
defp format_stack(stacktrace) when is_list(stacktrace) do
stacktrace
|> Enum.take(8)
|> Enum.map_join("\n", &Exception.format_stacktrace_entry/1)
rescue
_ -> nil
end
defp format_stack(_), do: nil
@doc "Total memory used by the Erlang VM, in bytes. Best-effort."
def memory_bytes do
:erlang.memory(:total)
rescue
_ -> nil
end
@doc "Memory delta in MB since `start_bytes`. Returns 0 when unavailable."
def memory_delta_mb(nil), do: 0
def memory_delta_mb(start_bytes) when is_integer(start_bytes) do
case memory_bytes() do
nil -> 0
now -> max(round((now - start_bytes) / (1024 * 1024)), 0)
end
rescue
_ -> 0
end
end