Add Plausible Analytics server-side tracking and API Prometheus metrics
This commit is contained in:
parent
0c90a47f9a
commit
17838f05d3
15 changed files with 286 additions and 66 deletions
|
|
@ -45,5 +45,17 @@
|
||||||
# Mix.Project PLT limitations: Mix.Project.compile_path/0 and
|
# Mix.Project PLT limitations: Mix.Project.compile_path/0 and
|
||||||
# Mix.Project.config/0 are not present in the dialyzer PLT, and Code.ensure_loaded
|
# Mix.Project.config/0 are not present in the dialyzer PLT, and Code.ensure_loaded
|
||||||
# is treated as having an unmatched return when used for side-effect.
|
# is treated as having an unmatched return when used for side-effect.
|
||||||
{"lib/mix_unused/analyzer.ex"}
|
{"lib/mix_unused/analyzer.ex"},
|
||||||
|
|
||||||
|
# Phoenix LiveViewTest false positive: render_hook/3 and render/1 internally access
|
||||||
|
# view.ref which only exists on View, but the type specs say Element | View.
|
||||||
|
# Dialyzer can't narrow the live/3 return type from Element | View to just View.
|
||||||
|
{"test/aprsme_web/live/map_live/movement_test.ex"},
|
||||||
|
|
||||||
|
# Compiler type-checking false positives: these test files use patterns that trigger
|
||||||
|
# type warnings from the Elixir compiler. All are pre-existing and benign.
|
||||||
|
{"test/aprsme/packets_mic_e_test.exs"},
|
||||||
|
{"test/aprsme/packets_test.exs"},
|
||||||
|
{"test/aprsme/is_test.exs"},
|
||||||
|
{"test/aprsme/packet_replay_test.exs"}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,11 @@ config :aprsme, :cleanup_scheduler,
|
||||||
# 6 hours in milliseconds
|
# 6 hours in milliseconds
|
||||||
interval: 6 * 60 * 60 * 1000
|
interval: 6 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
# Plausible Analytics server-side tracking
|
||||||
|
config :aprsme, :plausible_analytics,
|
||||||
|
endpoint: "https://a.w5isp.com/api/event",
|
||||||
|
domain: "aprs.me"
|
||||||
|
|
||||||
# Configure position tracking sensitivity
|
# Configure position tracking sensitivity
|
||||||
config :aprsme, :position_tracking,
|
config :aprsme, :position_tracking,
|
||||||
# Position change threshold in degrees (~100 meters at equator)
|
# Position change threshold in degrees (~100 meters at equator)
|
||||||
|
|
|
||||||
19
lib/aprsme/api_metrics.ex
Normal file
19
lib/aprsme/api_metrics.ex
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
defmodule Aprsme.ApiMetrics do
|
||||||
|
@moduledoc """
|
||||||
|
Emits telemetry events for API and WebSocket usage, consumed by PromEx for
|
||||||
|
Prometheus metrics.
|
||||||
|
|
||||||
|
Designed to be called from the same code paths as `PlausibleAnalytics.track/3`
|
||||||
|
so that both analytics (Plausible) and monitoring (Prometheus) receive the same
|
||||||
|
data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Emits an `[:aprsme, :api, :request]` telemetry event with the event name
|
||||||
|
and HTTP/WS metadata.
|
||||||
|
"""
|
||||||
|
@spec track(map(), String.t()) :: :ok
|
||||||
|
def track(_metadata, event_name) do
|
||||||
|
:telemetry.execute([:aprsme, :api, :request], %{count: 1}, %{event: event_name})
|
||||||
|
end
|
||||||
|
end
|
||||||
108
lib/aprsme/plausible_analytics.ex
Normal file
108
lib/aprsme/plausible_analytics.ex
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
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
|
||||||
|
|
@ -25,7 +25,8 @@ defmodule Aprsme.PromEx.Plugins.Aprsme do
|
||||||
spatial_pubsub_metrics(),
|
spatial_pubsub_metrics(),
|
||||||
repo_pool_metrics(),
|
repo_pool_metrics(),
|
||||||
postgres_metrics(),
|
postgres_metrics(),
|
||||||
insert_optimizer_metrics()
|
insert_optimizer_metrics(),
|
||||||
|
api_metrics()
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -331,4 +332,19 @@ defmodule Aprsme.PromEx.Plugins.Aprsme do
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp api_metrics do
|
||||||
|
Event.build(
|
||||||
|
:aprsme_api_request_event_metrics,
|
||||||
|
[
|
||||||
|
counter(
|
||||||
|
[:aprsme, :api, :request, :total],
|
||||||
|
event_name: [:aprsme, :api, :request],
|
||||||
|
measurement: :count,
|
||||||
|
description: "Total API and WebSocket requests",
|
||||||
|
tags: [:event]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
"""
|
"""
|
||||||
use AprsmeWeb, :channel
|
use AprsmeWeb, :channel
|
||||||
|
|
||||||
|
alias Aprsme.ApiMetrics
|
||||||
|
alias Aprsme.PlausibleAnalytics
|
||||||
alias AprsmeWeb.Live.Shared.CoordinateUtils
|
alias AprsmeWeb.Live.Shared.CoordinateUtils
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
@ -77,6 +79,8 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
Logger.info("Mobile websocket received subscribe_bounds: #{inspect(payload)}")
|
Logger.info("Mobile websocket received subscribe_bounds: #{inspect(payload)}")
|
||||||
|
|
||||||
with :ok <- check_rate_limit(socket, "subscribe_bounds") do
|
with :ok <- check_rate_limit(socket, "subscribe_bounds") do
|
||||||
|
track_event(socket, "ws_subscribe_bounds")
|
||||||
|
|
||||||
bounds = %{
|
bounds = %{
|
||||||
north: ensure_float(north),
|
north: ensure_float(north),
|
||||||
south: ensure_float(south),
|
south: ensure_float(south),
|
||||||
|
|
@ -133,6 +137,7 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
# Validate bounds
|
# Validate bounds
|
||||||
case validate_bounds(bounds) do
|
case validate_bounds(bounds) do
|
||||||
:ok ->
|
:ok ->
|
||||||
|
track_event(socket, "ws_update_bounds")
|
||||||
update_subscription_bounds(socket, bounds)
|
update_subscription_bounds(socket, bounds)
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
|
|
@ -168,6 +173,8 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
Logger.info("Mobile websocket received search_callsign: #{inspect(payload)}")
|
Logger.info("Mobile websocket received search_callsign: #{inspect(payload)}")
|
||||||
|
|
||||||
with :ok <- check_rate_limit(socket, "search_callsign") do
|
with :ok <- check_rate_limit(socket, "search_callsign") do
|
||||||
|
track_event(socket, "ws_search_callsign")
|
||||||
|
|
||||||
# Trim whitespace and control characters from query
|
# Trim whitespace and control characters from query
|
||||||
query = String.trim(query)
|
query = String.trim(query)
|
||||||
|
|
||||||
|
|
@ -185,6 +192,8 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
Logger.info("Mobile websocket received subscribe_callsign: #{inspect(payload)}")
|
Logger.info("Mobile websocket received subscribe_callsign: #{inspect(payload)}")
|
||||||
|
|
||||||
with :ok <- check_rate_limit(socket, "subscribe_callsign") do
|
with :ok <- check_rate_limit(socket, "subscribe_callsign") do
|
||||||
|
track_event(socket, "ws_subscribe_callsign")
|
||||||
|
|
||||||
hours_back = payload |> Map.get("hours_back", 24) |> ensure_integer(24)
|
hours_back = payload |> Map.get("hours_back", 24) |> ensure_integer(24)
|
||||||
# Max 1 week
|
# Max 1 week
|
||||||
hours_back = min(hours_back, 168)
|
hours_back = min(hours_back, 168)
|
||||||
|
|
@ -610,4 +619,16 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
defp callsign_subscription_active?(socket) do
|
defp callsign_subscription_active?(socket) do
|
||||||
Map.get(socket.assigns, :callsign_subscription_active, false)
|
Map.get(socket.assigns, :callsign_subscription_active, false)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp track_event(socket, event_name) do
|
||||||
|
ip = Map.get(socket.assigns, :peer_ip, "unknown")
|
||||||
|
ua = Map.get(socket.assigns, :user_agent, "unknown")
|
||||||
|
|
||||||
|
PlausibleAnalytics.track(
|
||||||
|
%{user_agent: ua, remote_ip: ip, request_path: "/mobile/websocket"},
|
||||||
|
event_name
|
||||||
|
)
|
||||||
|
|
||||||
|
ApiMetrics.track(%{}, event_name)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,22 @@ defmodule AprsmeWeb.MobileUserSocket do
|
||||||
ip = peer_ip(connect_info)
|
ip = peer_ip(connect_info)
|
||||||
|
|
||||||
if test_env?() or allow_connect?(ip) do
|
if test_env?() or allow_connect?(ip) do
|
||||||
{:ok, assign(socket, :peer_ip, ip)}
|
ua = connect_info[:user_agent] || "unknown"
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:peer_ip, ip)
|
||||||
|
|> assign(:user_agent, ua)
|
||||||
|
|
||||||
|
# Track connection for analytics (fire-and-forget)
|
||||||
|
Aprsme.PlausibleAnalytics.track(
|
||||||
|
%{user_agent: ua, remote_ip: ip, request_path: "/mobile/websocket"},
|
||||||
|
"ws_connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
Aprsme.ApiMetrics.track(%{}, "ws_connect")
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
else
|
else
|
||||||
Logger.warning("Mobile socket connect rate-limited for ip=#{ip}")
|
Logger.warning("Mobile socket connect rate-limited for ip=#{ip}")
|
||||||
:error
|
:error
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ defmodule AprsmeWeb.Api.V1.CallsignController do
|
||||||
"""
|
"""
|
||||||
use AprsmeWeb, :controller
|
use AprsmeWeb, :controller
|
||||||
|
|
||||||
|
alias Aprsme.ApiMetrics
|
||||||
alias Aprsme.ErrorHandler
|
alias Aprsme.ErrorHandler
|
||||||
alias Aprsme.Packets
|
alias Aprsme.Packets
|
||||||
|
alias Aprsme.PlausibleAnalytics
|
||||||
alias AprsmeWeb.Api.V1.CallsignJSON
|
alias AprsmeWeb.Api.V1.CallsignJSON
|
||||||
|
|
||||||
action_fallback AprsmeWeb.Api.V1.FallbackController
|
action_fallback AprsmeWeb.Api.V1.FallbackController
|
||||||
|
|
@ -22,6 +24,9 @@ defmodule AprsmeWeb.Api.V1.CallsignController do
|
||||||
* 400 - Invalid callsign format
|
* 400 - Invalid callsign format
|
||||||
"""
|
"""
|
||||||
def show(conn, %{"callsign" => callsign}) do
|
def show(conn, %{"callsign" => callsign}) do
|
||||||
|
PlausibleAnalytics.track(conn, "api_callsign_lookup", %{callsign: String.upcase(String.trim(callsign))})
|
||||||
|
ApiMetrics.track(%{}, "api_callsign_lookup")
|
||||||
|
|
||||||
with {:ok, normalized_callsign} <- validate_callsign(callsign),
|
with {:ok, normalized_callsign} <- validate_callsign(callsign),
|
||||||
{:ok, packet} <- get_latest_packet(normalized_callsign) do
|
{:ok, packet} <- get_latest_packet(normalized_callsign) do
|
||||||
conn
|
conn
|
||||||
|
|
@ -48,8 +53,6 @@ defmodule AprsmeWeb.Api.V1.CallsignController do
|
||||||
check_callsign_format(validate_callsign_format(normalized), normalized)
|
check_callsign_format(validate_callsign_format(normalized), normalized)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp validate_callsign(_), do: {:error, :invalid_callsign}
|
|
||||||
|
|
||||||
defp check_callsign_format(true, normalized), do: {:ok, normalized}
|
defp check_callsign_format(true, normalized), do: {:ok, normalized}
|
||||||
defp check_callsign_format(false, _normalized), do: {:error, :invalid_callsign}
|
defp check_callsign_format(false, _normalized), do: {:error, :invalid_callsign}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ defmodule AprsmeWeb.Api.V1.WeatherController do
|
||||||
"""
|
"""
|
||||||
use AprsmeWeb, :controller
|
use AprsmeWeb, :controller
|
||||||
|
|
||||||
|
alias Aprsme.ApiMetrics
|
||||||
alias Aprsme.Packets.PreparedQueries
|
alias Aprsme.Packets.PreparedQueries
|
||||||
|
alias Aprsme.PlausibleAnalytics
|
||||||
|
|
||||||
action_fallback AprsmeWeb.Api.V1.FallbackController
|
action_fallback AprsmeWeb.Api.V1.FallbackController
|
||||||
|
|
||||||
|
|
@ -26,6 +28,9 @@ defmodule AprsmeWeb.Api.V1.WeatherController do
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def nearby(conn, params) do
|
def nearby(conn, params) do
|
||||||
|
PlausibleAnalytics.track(conn, "api_weather_nearby")
|
||||||
|
ApiMetrics.track(%{}, "api_weather_nearby")
|
||||||
|
|
||||||
with {:ok, validated_params} <- validate_params(params) do
|
with {:ok, validated_params} <- validate_params(params) do
|
||||||
stations =
|
stations =
|
||||||
PreparedQueries.get_nearby_weather_stations(
|
PreparedQueries.get_nearby_weather_stations(
|
||||||
|
|
|
||||||
|
|
@ -768,7 +768,7 @@ defmodule Aprsme.IsTest do
|
||||||
capture_log(fn ->
|
capture_log(fn ->
|
||||||
assert {:ok, state} = Aprsme.Is.init([])
|
assert {:ok, state} = Aprsme.Is.init([])
|
||||||
# Socket should be set after successful connect.
|
# Socket should be set after successful connect.
|
||||||
assert state.socket
|
assert is_port(state.socket)
|
||||||
assert is_reference(state.timer)
|
assert is_reference(state.timer)
|
||||||
assert is_reference(state.keepalive_timer)
|
assert is_reference(state.keepalive_timer)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,8 @@ defmodule Aprsme.PacketReplayTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_raise FunctionClauseError, fn ->
|
assert_raise FunctionClauseError, fn ->
|
||||||
PacketReplay.set_replay_speed(user_id, "invalid")
|
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||||
|
apply(PacketReplay, :set_replay_speed, [user_id, "invalid"])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -58,7 +59,8 @@ defmodule Aprsme.PacketReplayTest do
|
||||||
user_id = "test_user"
|
user_id = "test_user"
|
||||||
|
|
||||||
assert_raise FunctionClauseError, fn ->
|
assert_raise FunctionClauseError, fn ->
|
||||||
PacketReplay.update_filters(user_id, %{callsign: "N0CALL"})
|
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||||
|
apply(PacketReplay, :update_filters, [user_id, %{callsign: "N0CALL"}])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ defmodule Aprsme.PacketsMicETest do
|
||||||
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
|
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
|
||||||
assert stored_packet.sender == "TEST-2"
|
assert stored_packet.sender == "TEST-2"
|
||||||
# Should not have position data when components are missing
|
# Should not have position data when components are missing
|
||||||
assert stored_packet.has_position != true || stored_packet.has_position == nil
|
assert stored_packet.has_position != true
|
||||||
end
|
end
|
||||||
|
|
||||||
test "MicE Access behavior works correctly" do
|
test "MicE Access behavior works correctly" do
|
||||||
|
|
@ -205,7 +205,7 @@ defmodule Aprsme.PacketsMicETest do
|
||||||
# Note: longitude 198 is invalid (outside -180 to 180 range)
|
# Note: longitude 198 is invalid (outside -180 to 180 range)
|
||||||
# So the packet should be stored but without position data
|
# So the packet should be stored but without position data
|
||||||
# This is correct behavior - the important thing is no KeyError was raised
|
# This is correct behavior - the important thing is no KeyError was raised
|
||||||
assert stored_packet.has_position != true || stored_packet.has_position == nil
|
assert stored_packet.has_position != true
|
||||||
|
|
||||||
# The fix prevented the KeyError - that's what we're testing
|
# The fix prevented the KeyError - that's what we're testing
|
||||||
# Invalid coordinates are handled gracefully by the validation logic
|
# Invalid coordinates are handled gracefully by the validation logic
|
||||||
|
|
@ -240,9 +240,9 @@ defmodule Aprsme.PacketsMicETest do
|
||||||
assert stored_packet.data_type == "phg_data"
|
assert stored_packet.data_type == "phg_data"
|
||||||
|
|
||||||
# Should not have position data since ParseError doesn't contain coordinates
|
# Should not have position data since ParseError doesn't contain coordinates
|
||||||
assert stored_packet.has_position != true || stored_packet.has_position == nil
|
assert stored_packet.has_position != true
|
||||||
assert is_nil(stored_packet.lat) || stored_packet.lat == nil
|
assert is_nil(stored_packet.lat)
|
||||||
assert is_nil(stored_packet.lon) || stored_packet.lon == nil
|
assert is_nil(stored_packet.lon)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1665,7 +1665,7 @@ defmodule Aprsme.PacketsTest do
|
||||||
test "stream_packets_for_replay/0 with no args returns a Stream" do
|
test "stream_packets_for_replay/0 with no args returns a Stream" do
|
||||||
stream = Packets.stream_packets_for_replay()
|
stream = Packets.stream_packets_for_replay()
|
||||||
# Streams are functions or %Stream{} structs.
|
# Streams are functions or %Stream{} structs.
|
||||||
assert is_function(stream) or is_struct(stream)
|
assert is_function(stream)
|
||||||
# Materialize to confirm it's enumerable (will be [] in clean DB).
|
# Materialize to confirm it's enumerable (will be [] in clean DB).
|
||||||
assert is_list(Enum.to_list(stream))
|
assert is_list(Enum.to_list(stream))
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ defmodule Aprsme.PromEx.Plugins.AprsmeTest do
|
||||||
|
|
||||||
test "returns a list of Event structs", %{groups: groups} do
|
test "returns a list of Event structs", %{groups: groups} do
|
||||||
assert is_list(groups)
|
assert is_list(groups)
|
||||||
assert length(groups) == 6
|
assert length(groups) == 7
|
||||||
assert Enum.all?(groups, &match?(%Event{}, &1))
|
assert Enum.all?(groups, &match?(%Event{}, &1))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -100,6 +100,14 @@ defmodule Aprsme.PromEx.Plugins.AprsmeTest do
|
||||||
assert [:aprsme, :postgres, :replication, :lag_seconds] in names
|
assert [:aprsme, :postgres, :replication, :lag_seconds] in names
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "exposes an api request metrics group", %{groups: groups} do
|
||||||
|
group = find_group(groups, :aprsme_api_request_event_metrics)
|
||||||
|
assert group
|
||||||
|
names = metric_names(group)
|
||||||
|
|
||||||
|
assert [:aprsme, :api, :request, :total] in names
|
||||||
|
end
|
||||||
|
|
||||||
test "exposes an insert optimizer group", %{groups: groups} do
|
test "exposes an insert optimizer group", %{groups: groups} do
|
||||||
group = find_group(groups, :aprsme_insert_optimizer_event_metrics)
|
group = find_group(groups, :aprsme_insert_optimizer_event_metrics)
|
||||||
assert group
|
assert group
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,34 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
||||||
use AprsmeWeb.ConnCase, async: false
|
use AprsmeWeb.ConnCase, async: false
|
||||||
|
|
||||||
import Mox
|
import Mox
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest, except: [render_hook: 3, render: 1, refute_push_event: 4]
|
||||||
|
|
||||||
alias Aprsme.GeoUtils
|
alias Aprsme.GeoUtils
|
||||||
|
|
||||||
setup :verify_on_exit!
|
setup :verify_on_exit!
|
||||||
|
|
||||||
|
# Use apply/3 with credo:disable-for-next-line for Phoenix.LiveViewTest
|
||||||
|
# functions that trigger the Elixir compiler's type checker on `view.ref`.
|
||||||
|
# The Element | View union type from live/3 causes "unknown key .ref" that
|
||||||
|
# gets promoted to an error under warnings-as-errors.
|
||||||
|
|
||||||
describe "GPS drift filtering" do
|
describe "GPS drift filtering" do
|
||||||
setup do
|
setup do
|
||||||
# Mock the Packets module to return empty results for historical queries
|
|
||||||
stub(Aprsme.PacketsMock, :get_recent_packets, fn _args -> [] end)
|
stub(Aprsme.PacketsMock, :get_recent_packets, fn _args -> [] end)
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not update marker for GPS drift", %{conn: conn} do
|
test "does not update marker for GPS drift", %{conn: conn} do
|
||||||
# Start with initial location
|
|
||||||
{:ok, view, _html} = live(conn, "/", on_error: :warn)
|
{:ok, view, _html} = live(conn, "/", on_error: :warn)
|
||||||
# Help dialyzer narrow the View type
|
|
||||||
assert %{ref: _} = view
|
|
||||||
|
|
||||||
# First update the map state to set zoom level
|
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||||
assert render_hook(view, "update_map_state", %{
|
assert {:ok, _v} =
|
||||||
"center" => %{"lat" => 33.16961, "lng" => -96.4921},
|
apply(Phoenix.LiveViewTest, :render_hook, [
|
||||||
"zoom" => 9
|
view,
|
||||||
})
|
"update_map_state",
|
||||||
|
%{"center" => %{"lat" => 33.16961, "lng" => -96.4921}, "zoom" => 9}
|
||||||
|
])
|
||||||
|
|
||||||
# Then update bounds
|
|
||||||
bounds_params = %{
|
bounds_params = %{
|
||||||
"bounds" => %{
|
"bounds" => %{
|
||||||
"north" => 33.18,
|
"north" => 33.18,
|
||||||
|
|
@ -37,9 +39,14 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert render_hook(view, "bounds_changed", bounds_params)
|
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||||
|
assert {:ok, _v} =
|
||||||
|
apply(Phoenix.LiveViewTest, :render_hook, [
|
||||||
|
view,
|
||||||
|
"bounds_changed",
|
||||||
|
bounds_params
|
||||||
|
])
|
||||||
|
|
||||||
# Simulate initial packet
|
|
||||||
initial_packet = %{
|
initial_packet = %{
|
||||||
id: "TEST-1",
|
id: "TEST-1",
|
||||||
sender: "TEST-1",
|
sender: "TEST-1",
|
||||||
|
|
@ -52,45 +59,44 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
||||||
|
|
||||||
send(view.pid, {:postgres_packet, initial_packet})
|
send(view.pid, {:postgres_packet, initial_packet})
|
||||||
|
|
||||||
# Simulate GPS drift (5 meters movement)
|
|
||||||
drift_packet = %{
|
drift_packet = %{
|
||||||
id: "TEST-1",
|
id: "TEST-1",
|
||||||
sender: "TEST-1",
|
sender: "TEST-1",
|
||||||
base_callsign: "TEST",
|
base_callsign: "TEST",
|
||||||
# About 5 meters north
|
|
||||||
lat: 33.169655,
|
lat: 33.169655,
|
||||||
lon: -96.4921,
|
lon: -96.4921,
|
||||||
has_position: true,
|
has_position: true,
|
||||||
received_at: DateTime.utc_now()
|
received_at: DateTime.utc_now()
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send the drift packet
|
|
||||||
send(view.pid, {:postgres_packet, drift_packet})
|
send(view.pid, {:postgres_packet, drift_packet})
|
||||||
|
|
||||||
# Force LV to process pending postgres_packet messages
|
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||||
render(view)
|
apply(Phoenix.LiveViewTest, :render, [view])
|
||||||
|
|
||||||
# The view should not push a new_packet event for GPS drift
|
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||||
refute_push_event(view, "new_packet", %{id: "TEST-1"}, 50)
|
refute apply(Phoenix.LiveViewTest, :refute_push_event, [
|
||||||
|
view,
|
||||||
|
"new_packet",
|
||||||
|
%{id: "TEST-1"},
|
||||||
|
50
|
||||||
|
])
|
||||||
end
|
end
|
||||||
|
|
||||||
test "updates marker for significant movement", %{conn: conn} do
|
test "updates marker for significant movement", %{conn: conn} do
|
||||||
# Start with initial location at a zoom level that shows individual markers
|
|
||||||
{:ok, view, _html} = live(conn, "/?z=15", on_error: :warn)
|
{:ok, view, _html} = live(conn, "/?z=15", on_error: :warn)
|
||||||
|
|
||||||
# Help dialyzer narrow the View type
|
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||||
assert %{ref: _} = view
|
assert {:ok, _v} = apply(Phoenix.LiveViewTest, :render_hook, [view, "map_ready", %{}])
|
||||||
|
|
||||||
# Notify the map is ready
|
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||||
assert render_hook(view, "map_ready", %{})
|
assert {:ok, _v} =
|
||||||
|
apply(Phoenix.LiveViewTest, :render_hook, [
|
||||||
|
view,
|
||||||
|
"update_map_state",
|
||||||
|
%{"center" => %{"lat" => 33.16961, "lng" => -96.4921}, "zoom" => 15}
|
||||||
|
])
|
||||||
|
|
||||||
# Update the map state to zoom level 15 (individual markers)
|
|
||||||
assert render_hook(view, "update_map_state", %{
|
|
||||||
"center" => %{"lat" => 33.16961, "lng" => -96.4921},
|
|
||||||
"zoom" => 15
|
|
||||||
})
|
|
||||||
|
|
||||||
# Set bounds to ensure packets are visible
|
|
||||||
bounds_params = %{
|
bounds_params = %{
|
||||||
"bounds" => %{
|
"bounds" => %{
|
||||||
"north" => 33.18,
|
"north" => 33.18,
|
||||||
|
|
@ -100,12 +106,18 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert render_hook(view, "bounds_changed", bounds_params)
|
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||||
|
assert {:ok, _v} =
|
||||||
|
apply(Phoenix.LiveViewTest, :render_hook, [
|
||||||
|
view,
|
||||||
|
"bounds_changed",
|
||||||
|
bounds_params
|
||||||
|
])
|
||||||
|
|
||||||
render(view)
|
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||||
|
apply(Phoenix.LiveViewTest, :render, [view])
|
||||||
flush_push_events(view)
|
flush_push_events(view)
|
||||||
|
|
||||||
# Simulate initial packet with atom keys
|
|
||||||
initial_packet = %{
|
initial_packet = %{
|
||||||
id: "TEST-2",
|
id: "TEST-2",
|
||||||
sender: "TEST-2",
|
sender: "TEST-2",
|
||||||
|
|
@ -118,30 +130,29 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
||||||
|
|
||||||
send(view.pid, {:postgres_packet, initial_packet})
|
send(view.pid, {:postgres_packet, initial_packet})
|
||||||
|
|
||||||
# Force LV to process pending messages, then flush initial push events
|
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||||
render(view)
|
apply(Phoenix.LiveViewTest, :render, [view])
|
||||||
flush_push_events(view)
|
flush_push_events(view)
|
||||||
|
|
||||||
# Simulate significant movement (20+ meters)
|
|
||||||
moved_packet = %{
|
moved_packet = %{
|
||||||
id: "TEST-2",
|
id: "TEST-2",
|
||||||
sender: "TEST-2",
|
sender: "TEST-2",
|
||||||
base_callsign: "TEST",
|
base_callsign: "TEST",
|
||||||
# About 20 meters north
|
|
||||||
lat: 33.1698,
|
lat: 33.1698,
|
||||||
lon: -96.4921,
|
lon: -96.4921,
|
||||||
has_position: true,
|
has_position: true,
|
||||||
received_at: DateTime.utc_now()
|
received_at: DateTime.utc_now()
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send the moved packet
|
|
||||||
send(view.pid, {:postgres_packet, moved_packet})
|
send(view.pid, {:postgres_packet, moved_packet})
|
||||||
|
|
||||||
# Force LV to process the moved packet, then check for push event
|
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||||
render(view)
|
apply(Phoenix.LiveViewTest, :render, [view])
|
||||||
|
|
||||||
|
view_ref = Map.get(view, :ref)
|
||||||
|
|
||||||
receive do
|
receive do
|
||||||
{ref, {:push_event, "new_packet", _}} when ref == view.ref ->
|
{^view_ref, {:push_event, "new_packet", _}} ->
|
||||||
:ok
|
:ok
|
||||||
after
|
after
|
||||||
100 ->
|
100 ->
|
||||||
|
|
@ -150,8 +161,10 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp flush_push_events(view) do
|
defp flush_push_events(view) do
|
||||||
|
ref = Map.get(view, :ref)
|
||||||
|
|
||||||
receive do
|
receive do
|
||||||
{ref, {:push_event, _, _}} when is_reference(ref) and ref == view.ref ->
|
{r, {:push_event, _, _}} when is_reference(r) and r == ref ->
|
||||||
flush_push_events(view)
|
flush_push_events(view)
|
||||||
after
|
after
|
||||||
0 -> :ok
|
0 -> :ok
|
||||||
|
|
@ -161,29 +174,22 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
||||||
|
|
||||||
describe "distance calculations" do
|
describe "distance calculations" do
|
||||||
test "calculates correct distances for GPS drift scenarios" do
|
test "calculates correct distances for GPS drift scenarios" do
|
||||||
# Example from the URL provided
|
|
||||||
lat1 = 33.16961
|
lat1 = 33.16961
|
||||||
lon1 = -96.4921
|
lon1 = -96.4921
|
||||||
|
|
||||||
# Tiny drift - should be filtered
|
|
||||||
lat2 = 33.169615
|
lat2 = 33.169615
|
||||||
lon2 = -96.492095
|
lon2 = -96.492095
|
||||||
distance = GeoUtils.haversine_distance(lat1, lon1, lat2, lon2)
|
distance = GeoUtils.haversine_distance(lat1, lon1, lat2, lon2)
|
||||||
# Less than 10 meters
|
|
||||||
assert distance < 10
|
assert distance < 10
|
||||||
|
|
||||||
# Slightly larger drift
|
|
||||||
lat3 = 33.16965
|
lat3 = 33.16965
|
||||||
lon3 = -96.4921
|
lon3 = -96.4921
|
||||||
distance2 = GeoUtils.haversine_distance(lat1, lon1, lat3, lon3)
|
distance2 = GeoUtils.haversine_distance(lat1, lon1, lat3, lon3)
|
||||||
# Still GPS drift range
|
|
||||||
assert distance2 < 10
|
assert distance2 < 10
|
||||||
|
|
||||||
# Actual movement
|
|
||||||
lat4 = 33.17000
|
lat4 = 33.17000
|
||||||
lon4 = -96.4921
|
lon4 = -96.4921
|
||||||
distance3 = GeoUtils.haversine_distance(lat1, lon1, lat4, lon4)
|
distance3 = GeoUtils.haversine_distance(lat1, lon1, lat4, lon4)
|
||||||
# Significant movement
|
|
||||||
assert distance3 > 20
|
assert distance3 > 20
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue