Adds spans to 15 previously-unmeasured hot paths so every question we might ask while tuning has a histogram to answer it: External I/O: - iem.fetch_iemre (gridded weather reanalysis) - mrms.list_latest / mrms.download (precip radar) - rtma.fetch_observation - ncei.fetch_metar (historical 5-min METAR backfill) - solar.fetch_indices (GFZ solar indices) - swpc.fetch (SWPC Kp/F10.7/X-ray) - giro.fetch (ionosonde) - qrz.request, geocoder.geocode (callsign enrichment) - srtm.download_tile (terrain tile download + gunzip) - hrrr.download_grib_ranges (parallel byte-range fetch phase) Subprocess: - wgrib2.extract_grid / extract_grid_from_file / extract_grid_from_file_mapped LiveView hot paths: - propagation.scores_at (map score fetch + cache hit/miss counter) - propagation.point_forecast (sparkline) - propagation.point_detail (click-to-inspect) - propagation.daily_outlook_at (/map outlook strip) Worker-level end-to-end: - worker.terrain_profile - worker.mechanism_classify - worker.mrms_fetch Each event is registered in Microwaveprop.PromEx.InstrumentPlugin as a Prometheus histogram (default / long buckets as appropriate) plus a counter for the scores_at cache hit/miss ratio. Prometheus at 10.0.15.25 will start seeing the new series on the next scrape after deploy.
191 lines
4.7 KiB
Elixir
191 lines
4.7 KiB
Elixir
defmodule Microwaveprop.Qrz.Client do
|
|
@moduledoc """
|
|
HTTP client for the QRZ.com XML callsign API.
|
|
|
|
Maintains a session key in an `Agent` and transparently re-logs-in
|
|
when the upstream returns a `session_expired` response. The session
|
|
key is cached process-local because QRZ rate-limits login calls.
|
|
"""
|
|
use Agent
|
|
|
|
require Record
|
|
|
|
Record.defrecord(:xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl"))
|
|
Record.defrecord(:xmlText, Record.extract(:xmlText, from_lib: "xmerl/include/xmerl.hrl"))
|
|
|
|
@url "https://xmldata.qrz.com/xml/current/"
|
|
|
|
@spec start_link(keyword()) :: Agent.on_start()
|
|
def start_link(_) do
|
|
Agent.start_link(fn -> nil end, name: __MODULE__)
|
|
end
|
|
|
|
@spec reset_session() :: :ok
|
|
def reset_session do
|
|
Agent.update(__MODULE__, fn _ -> nil end)
|
|
end
|
|
|
|
@spec login() :: {:ok, String.t()} | {:error, String.t()}
|
|
def login do
|
|
config = Application.get_env(:microwaveprop, __MODULE__, [])
|
|
username = Keyword.fetch!(config, :username)
|
|
password = Keyword.fetch!(config, :password)
|
|
agent = Keyword.get(config, :agent, "microwaveprop/1.0")
|
|
|
|
params = %{username: username, password: password, agent: agent}
|
|
|
|
with {:ok, xml} <- do_request(params),
|
|
{:ok, key} <- parse_session(xml) do
|
|
Agent.update(__MODULE__, fn _ -> key end)
|
|
{:ok, key}
|
|
end
|
|
end
|
|
|
|
@spec lookup(String.t()) :: {:ok, map()} | {:error, String.t()}
|
|
def lookup(callsign) do
|
|
with {:ok, key} <- ensure_session() do
|
|
do_lookup(callsign, key, true)
|
|
end
|
|
end
|
|
|
|
defp ensure_session do
|
|
case Agent.get(__MODULE__, & &1) do
|
|
nil -> login()
|
|
key -> {:ok, key}
|
|
end
|
|
end
|
|
|
|
defp do_lookup(callsign, key, retry?) do
|
|
params = %{s: key, callsign: callsign}
|
|
|
|
with {:ok, xml} <- do_request(params) do
|
|
xml
|
|
|> parse_callsign_response()
|
|
|> handle_lookup_result(callsign, retry?)
|
|
end
|
|
end
|
|
|
|
defp handle_lookup_result({:ok, _} = success, _, _), do: success
|
|
|
|
defp handle_lookup_result({:error, :session_expired}, callsign, true) do
|
|
Agent.update(__MODULE__, fn _ -> nil end)
|
|
|
|
with {:ok, new_key} <- login() do
|
|
do_lookup(callsign, new_key, false)
|
|
end
|
|
end
|
|
|
|
defp handle_lookup_result({:error, :session_expired}, _, false) do
|
|
{:error, "Session expired after retry"}
|
|
end
|
|
|
|
defp handle_lookup_result({:error, _} = error, _, _), do: error
|
|
|
|
defp do_request(params) do
|
|
Microwaveprop.Instrument.span([:qrz, :request], %{}, fn ->
|
|
config = Application.get_env(:microwaveprop, __MODULE__, [])
|
|
|
|
req_opts =
|
|
[url: @url, params: params]
|
|
|> maybe_add_plug(config)
|
|
|> maybe_add_retry(config)
|
|
|
|
case Req.get(req_opts) do
|
|
{:ok, %{status: 200, body: body}} ->
|
|
{:ok, body}
|
|
|
|
{:ok, %{status: status}} ->
|
|
{:error, "HTTP #{status}"}
|
|
|
|
{:error, reason} ->
|
|
{:error, "Request failed: #{inspect(reason)}"}
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp maybe_add_plug(opts, config) do
|
|
case Keyword.get(config, :plug) do
|
|
nil -> opts
|
|
plug -> Keyword.put(opts, :plug, plug)
|
|
end
|
|
end
|
|
|
|
defp maybe_add_retry(opts, config) do
|
|
case Keyword.get(config, :retry) do
|
|
nil -> opts
|
|
retry -> Keyword.put(opts, :retry, retry)
|
|
end
|
|
end
|
|
|
|
defp parse_session(xml) do
|
|
bytes = :binary.bin_to_list(xml)
|
|
{doc, _} = :xmerl_scan.string(bytes)
|
|
|
|
case xpath_text(doc, ~c"//Session/Key") do
|
|
nil ->
|
|
error = xpath_text(doc, ~c"//Session/Error") || "Unknown error"
|
|
{:error, error}
|
|
|
|
key ->
|
|
{:ok, key}
|
|
end
|
|
end
|
|
|
|
defp parse_callsign_response(xml) do
|
|
bytes = :binary.bin_to_list(xml)
|
|
{doc, _} = :xmerl_scan.string(bytes)
|
|
|
|
callsign_elements = :xmerl_xpath.string(~c"//Callsign/*", doc)
|
|
|
|
if callsign_elements == [] do
|
|
case xpath_text(doc, ~c"//Session/Key") do
|
|
nil ->
|
|
{:error, :session_expired}
|
|
|
|
_ ->
|
|
error = xpath_text(doc, ~c"//Session/Error") || "Not found"
|
|
{:error, error}
|
|
end
|
|
else
|
|
data =
|
|
callsign_elements
|
|
|> Enum.filter(&Record.is_record(&1, :xmlElement))
|
|
|> Map.new(fn elem ->
|
|
name =
|
|
elem
|
|
|> xmlElement(:name)
|
|
|> to_string()
|
|
|
|
value = extract_text(elem)
|
|
{name, value}
|
|
end)
|
|
|
|
{:ok, data}
|
|
end
|
|
end
|
|
|
|
defp xpath_text(doc, path) do
|
|
case :xmerl_xpath.string(path, doc) do
|
|
[] ->
|
|
nil
|
|
|
|
[element | _] ->
|
|
extract_text(element)
|
|
end
|
|
end
|
|
|
|
defp extract_text(element) do
|
|
element
|
|
|> xmlElement(:content)
|
|
|> Enum.filter(&Record.is_record(&1, :xmlText))
|
|
|> Enum.map_join(fn text_node ->
|
|
text_node
|
|
|> xmlText(:value)
|
|
|> to_string()
|
|
end)
|
|
|> then(fn
|
|
"" -> nil
|
|
text -> text
|
|
end)
|
|
end
|
|
end
|