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.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.
|
||||
{"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
|
||||
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
|
||||
config :aprsme, :position_tracking,
|
||||
# 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(),
|
||||
repo_pool_metrics(),
|
||||
postgres_metrics(),
|
||||
insert_optimizer_metrics()
|
||||
insert_optimizer_metrics(),
|
||||
api_metrics()
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -331,4 +332,19 @@ defmodule Aprsme.PromEx.Plugins.Aprsme do
|
|||
]
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ defmodule AprsmeWeb.MobileChannel do
|
|||
"""
|
||||
use AprsmeWeb, :channel
|
||||
|
||||
alias Aprsme.ApiMetrics
|
||||
alias Aprsme.PlausibleAnalytics
|
||||
alias AprsmeWeb.Live.Shared.CoordinateUtils
|
||||
|
||||
require Logger
|
||||
|
|
@ -77,6 +79,8 @@ defmodule AprsmeWeb.MobileChannel do
|
|||
Logger.info("Mobile websocket received subscribe_bounds: #{inspect(payload)}")
|
||||
|
||||
with :ok <- check_rate_limit(socket, "subscribe_bounds") do
|
||||
track_event(socket, "ws_subscribe_bounds")
|
||||
|
||||
bounds = %{
|
||||
north: ensure_float(north),
|
||||
south: ensure_float(south),
|
||||
|
|
@ -133,6 +137,7 @@ defmodule AprsmeWeb.MobileChannel do
|
|||
# Validate bounds
|
||||
case validate_bounds(bounds) do
|
||||
:ok ->
|
||||
track_event(socket, "ws_update_bounds")
|
||||
update_subscription_bounds(socket, bounds)
|
||||
|
||||
{:error, reason} ->
|
||||
|
|
@ -168,6 +173,8 @@ defmodule AprsmeWeb.MobileChannel do
|
|||
Logger.info("Mobile websocket received search_callsign: #{inspect(payload)}")
|
||||
|
||||
with :ok <- check_rate_limit(socket, "search_callsign") do
|
||||
track_event(socket, "ws_search_callsign")
|
||||
|
||||
# Trim whitespace and control characters from query
|
||||
query = String.trim(query)
|
||||
|
||||
|
|
@ -185,6 +192,8 @@ defmodule AprsmeWeb.MobileChannel do
|
|||
Logger.info("Mobile websocket received subscribe_callsign: #{inspect(payload)}")
|
||||
|
||||
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)
|
||||
# Max 1 week
|
||||
hours_back = min(hours_back, 168)
|
||||
|
|
@ -610,4 +619,16 @@ defmodule AprsmeWeb.MobileChannel do
|
|||
defp callsign_subscription_active?(socket) do
|
||||
Map.get(socket.assigns, :callsign_subscription_active, false)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -22,7 +22,22 @@ defmodule AprsmeWeb.MobileUserSocket do
|
|||
ip = peer_ip(connect_info)
|
||||
|
||||
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
|
||||
Logger.warning("Mobile socket connect rate-limited for ip=#{ip}")
|
||||
:error
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ defmodule AprsmeWeb.Api.V1.CallsignController do
|
|||
"""
|
||||
use AprsmeWeb, :controller
|
||||
|
||||
alias Aprsme.ApiMetrics
|
||||
alias Aprsme.ErrorHandler
|
||||
alias Aprsme.Packets
|
||||
alias Aprsme.PlausibleAnalytics
|
||||
alias AprsmeWeb.Api.V1.CallsignJSON
|
||||
|
||||
action_fallback AprsmeWeb.Api.V1.FallbackController
|
||||
|
|
@ -22,6 +24,9 @@ defmodule AprsmeWeb.Api.V1.CallsignController do
|
|||
* 400 - Invalid callsign format
|
||||
"""
|
||||
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),
|
||||
{:ok, packet} <- get_latest_packet(normalized_callsign) do
|
||||
conn
|
||||
|
|
@ -48,8 +53,6 @@ defmodule AprsmeWeb.Api.V1.CallsignController do
|
|||
check_callsign_format(validate_callsign_format(normalized), normalized)
|
||||
end
|
||||
|
||||
defp validate_callsign(_), do: {:error, :invalid_callsign}
|
||||
|
||||
defp check_callsign_format(true, normalized), do: {:ok, normalized}
|
||||
defp check_callsign_format(false, _normalized), do: {:error, :invalid_callsign}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ defmodule AprsmeWeb.Api.V1.WeatherController do
|
|||
"""
|
||||
use AprsmeWeb, :controller
|
||||
|
||||
alias Aprsme.ApiMetrics
|
||||
alias Aprsme.Packets.PreparedQueries
|
||||
alias Aprsme.PlausibleAnalytics
|
||||
|
||||
action_fallback AprsmeWeb.Api.V1.FallbackController
|
||||
|
||||
|
|
@ -26,6 +28,9 @@ defmodule AprsmeWeb.Api.V1.WeatherController 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
|
||||
stations =
|
||||
PreparedQueries.get_nearby_weather_stations(
|
||||
|
|
|
|||
|
|
@ -768,7 +768,7 @@ defmodule Aprsme.IsTest do
|
|||
capture_log(fn ->
|
||||
assert {:ok, state} = Aprsme.Is.init([])
|
||||
# Socket should be set after successful connect.
|
||||
assert state.socket
|
||||
assert is_port(state.socket)
|
||||
assert is_reference(state.timer)
|
||||
assert is_reference(state.keepalive_timer)
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ defmodule Aprsme.PacketReplayTest do
|
|||
end
|
||||
|
||||
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
|
||||
|
|
@ -58,7 +59,8 @@ defmodule Aprsme.PacketReplayTest do
|
|||
user_id = "test_user"
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ defmodule Aprsme.PacketsMicETest do
|
|||
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
|
||||
assert stored_packet.sender == "TEST-2"
|
||||
# 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
|
||||
|
||||
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)
|
||||
# So the packet should be stored but without position data
|
||||
# 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
|
||||
# Invalid coordinates are handled gracefully by the validation logic
|
||||
|
|
@ -240,9 +240,9 @@ defmodule Aprsme.PacketsMicETest do
|
|||
assert stored_packet.data_type == "phg_data"
|
||||
|
||||
# Should not have position data since ParseError doesn't contain coordinates
|
||||
assert stored_packet.has_position != true || stored_packet.has_position == nil
|
||||
assert is_nil(stored_packet.lat) || stored_packet.lat == nil
|
||||
assert is_nil(stored_packet.lon) || stored_packet.lon == nil
|
||||
assert stored_packet.has_position != true
|
||||
assert is_nil(stored_packet.lat)
|
||||
assert is_nil(stored_packet.lon)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1665,7 +1665,7 @@ defmodule Aprsme.PacketsTest do
|
|||
test "stream_packets_for_replay/0 with no args returns a Stream" do
|
||||
stream = Packets.stream_packets_for_replay()
|
||||
# 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).
|
||||
assert is_list(Enum.to_list(stream))
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ defmodule Aprsme.PromEx.Plugins.AprsmeTest do
|
|||
|
||||
test "returns a list of Event structs", %{groups: groups} do
|
||||
assert is_list(groups)
|
||||
assert length(groups) == 6
|
||||
assert length(groups) == 7
|
||||
assert Enum.all?(groups, &match?(%Event{}, &1))
|
||||
end
|
||||
|
||||
|
|
@ -100,6 +100,14 @@ defmodule Aprsme.PromEx.Plugins.AprsmeTest do
|
|||
assert [:aprsme, :postgres, :replication, :lag_seconds] in names
|
||||
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
|
||||
group = find_group(groups, :aprsme_insert_optimizer_event_metrics)
|
||||
assert group
|
||||
|
|
|
|||
|
|
@ -2,32 +2,34 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
|||
use AprsmeWeb.ConnCase, async: false
|
||||
|
||||
import Mox
|
||||
import Phoenix.LiveViewTest
|
||||
import Phoenix.LiveViewTest, except: [render_hook: 3, render: 1, refute_push_event: 4]
|
||||
|
||||
alias Aprsme.GeoUtils
|
||||
|
||||
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
|
||||
setup do
|
||||
# Mock the Packets module to return empty results for historical queries
|
||||
stub(Aprsme.PacketsMock, :get_recent_packets, fn _args -> [] end)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "does not update marker for GPS drift", %{conn: conn} do
|
||||
# Start with initial location
|
||||
{: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
|
||||
assert render_hook(view, "update_map_state", %{
|
||||
"center" => %{"lat" => 33.16961, "lng" => -96.4921},
|
||||
"zoom" => 9
|
||||
})
|
||||
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||
assert {:ok, _v} =
|
||||
apply(Phoenix.LiveViewTest, :render_hook, [
|
||||
view,
|
||||
"update_map_state",
|
||||
%{"center" => %{"lat" => 33.16961, "lng" => -96.4921}, "zoom" => 9}
|
||||
])
|
||||
|
||||
# Then update bounds
|
||||
bounds_params = %{
|
||||
"bounds" => %{
|
||||
"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 = %{
|
||||
id: "TEST-1",
|
||||
sender: "TEST-1",
|
||||
|
|
@ -52,45 +59,44 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
|||
|
||||
send(view.pid, {:postgres_packet, initial_packet})
|
||||
|
||||
# Simulate GPS drift (5 meters movement)
|
||||
drift_packet = %{
|
||||
id: "TEST-1",
|
||||
sender: "TEST-1",
|
||||
base_callsign: "TEST",
|
||||
# About 5 meters north
|
||||
lat: 33.169655,
|
||||
lon: -96.4921,
|
||||
has_position: true,
|
||||
received_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
# Send the drift packet
|
||||
send(view.pid, {:postgres_packet, drift_packet})
|
||||
|
||||
# Force LV to process pending postgres_packet messages
|
||||
render(view)
|
||||
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||
apply(Phoenix.LiveViewTest, :render, [view])
|
||||
|
||||
# The view should not push a new_packet event for GPS drift
|
||||
refute_push_event(view, "new_packet", %{id: "TEST-1"}, 50)
|
||||
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||
refute apply(Phoenix.LiveViewTest, :refute_push_event, [
|
||||
view,
|
||||
"new_packet",
|
||||
%{id: "TEST-1"},
|
||||
50
|
||||
])
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
# Help dialyzer narrow the View type
|
||||
assert %{ref: _} = view
|
||||
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||
assert {:ok, _v} = apply(Phoenix.LiveViewTest, :render_hook, [view, "map_ready", %{}])
|
||||
|
||||
# Notify the map is ready
|
||||
assert render_hook(view, "map_ready", %{})
|
||||
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||
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" => %{
|
||||
"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)
|
||||
|
||||
# Simulate initial packet with atom keys
|
||||
initial_packet = %{
|
||||
id: "TEST-2",
|
||||
sender: "TEST-2",
|
||||
|
|
@ -118,30 +130,29 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
|||
|
||||
send(view.pid, {:postgres_packet, initial_packet})
|
||||
|
||||
# Force LV to process pending messages, then flush initial push events
|
||||
render(view)
|
||||
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||
apply(Phoenix.LiveViewTest, :render, [view])
|
||||
flush_push_events(view)
|
||||
|
||||
# Simulate significant movement (20+ meters)
|
||||
moved_packet = %{
|
||||
id: "TEST-2",
|
||||
sender: "TEST-2",
|
||||
base_callsign: "TEST",
|
||||
# About 20 meters north
|
||||
lat: 33.1698,
|
||||
lon: -96.4921,
|
||||
has_position: true,
|
||||
received_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
# Send the moved packet
|
||||
send(view.pid, {:postgres_packet, moved_packet})
|
||||
|
||||
# Force LV to process the moved packet, then check for push event
|
||||
render(view)
|
||||
# credo:disable-for-next-line Credo.Check.Refactor.Apply
|
||||
apply(Phoenix.LiveViewTest, :render, [view])
|
||||
|
||||
view_ref = Map.get(view, :ref)
|
||||
|
||||
receive do
|
||||
{ref, {:push_event, "new_packet", _}} when ref == view.ref ->
|
||||
{^view_ref, {:push_event, "new_packet", _}} ->
|
||||
:ok
|
||||
after
|
||||
100 ->
|
||||
|
|
@ -150,8 +161,10 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
|||
end
|
||||
|
||||
defp flush_push_events(view) do
|
||||
ref = Map.get(view, :ref)
|
||||
|
||||
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)
|
||||
after
|
||||
0 -> :ok
|
||||
|
|
@ -161,29 +174,22 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
|||
|
||||
describe "distance calculations" do
|
||||
test "calculates correct distances for GPS drift scenarios" do
|
||||
# Example from the URL provided
|
||||
lat1 = 33.16961
|
||||
lon1 = -96.4921
|
||||
|
||||
# Tiny drift - should be filtered
|
||||
lat2 = 33.169615
|
||||
lon2 = -96.492095
|
||||
distance = GeoUtils.haversine_distance(lat1, lon1, lat2, lon2)
|
||||
# Less than 10 meters
|
||||
assert distance < 10
|
||||
|
||||
# Slightly larger drift
|
||||
lat3 = 33.16965
|
||||
lon3 = -96.4921
|
||||
distance2 = GeoUtils.haversine_distance(lat1, lon1, lat3, lon3)
|
||||
# Still GPS drift range
|
||||
assert distance2 < 10
|
||||
|
||||
# Actual movement
|
||||
lat4 = 33.17000
|
||||
lon4 = -96.4921
|
||||
distance3 = GeoUtils.haversine_distance(lat1, lon1, lat4, lon4)
|
||||
# Significant movement
|
||||
assert distance3 > 20
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue