aprs.me/lib/aprsme/plausible_analytics.ex

108 lines
3.1 KiB
Elixir

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