defmodule Aprsme.PlausibleAnalytics do @moduledoc """ Server-side event tracking for Plausible Analytics (self-hosted at a.w5isp.com). Fires an async HTTP POST to the Plausible Events API. All calls are fire-and-forget — analytics must never block or error the caller. ## Configuration config :aprsme, :plausible_analytics, endpoint: "https://a.w5isp.com/api/event", domain: "aprs.me" ## Usage PlausibleAnalytics.track(conn, "ws_subscribe_bounds") PlausibleAnalytics.track(conn, "api_callsign_lookup", %{callsign: "N0CALL"}) """ require Logger @doc """ Track a custom event via the Plausible Events API. `conn_or_metadata` accepts either a `Plug.Conn` (for HTTP requests) or a map with `:remote_ip` / `:user_agent` / `:request_path` keys (for WebSocket channels). """ @spec track(Plug.Conn.t() | map(), String.t(), map()) :: :ok def track(conn_or_metadata, event_name, props \\ %{}) def track(%Plug.Conn{} = conn, event_name, props) do metadata = %{ user_agent: first_header(conn, "user-agent"), remote_ip: remote_ip_string(conn), request_path: conn.request_path } do_track(metadata, event_name, props) end def track(%{} = metadata, event_name, props) do do_track(metadata, event_name, props) end defp do_track(metadata, event_name, props) do _task = Task.start(fn -> send_event(metadata, event_name, props) end) :ok end defp send_event(metadata, event_name, props) do config = config() url = "https://aprs.me#{Map.get(metadata, :request_path, "/")}" body = maybe_add_props(%{name: event_name, domain: config[:domain], url: url}, props) headers = maybe_add_forwarded_for( [{"content-type", "application/json"}, {"user-agent", Map.get(metadata, :user_agent, "aprs.me-server")}], Map.get(metadata, :remote_ip) || Map.get(metadata, "remote_ip") ) case Req.post(config[:endpoint], json: body, headers: headers, max_retries: 0, receive_timeout: 2_000) do {:ok, %{status: status}} when status in 200..299 -> Logger.debug("Plausible event tracked: #{event_name}") {:ok, %{status: status}} -> Logger.warning("Plausible event rejected (#{status}): #{event_name}") {:error, error} -> Logger.warning("Plausible event failed: #{event_name} — #{inspect(error)}") end end defp maybe_add_props(body, props) when map_size(props) > 0 do Map.put(body, :props, props) end defp maybe_add_props(body, _props), do: body defp maybe_add_forwarded_for(headers, nil), do: headers defp maybe_add_forwarded_for(headers, ip) do [{"x-forwarded-for", ip} | headers] end defp first_header(conn, name) do conn |> Plug.Conn.get_req_header(name) |> List.first() end # Extract remote_ip as a string, handling the tuple format from Plug.Conn defp remote_ip_string(conn) do case conn.remote_ip do {a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}" other -> to_string(other) end end defp config do Application.get_env(:aprsme, :plausible_analytics, []) end end