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