prop/lib/microwaveprop/qrz/client.ex
Graham McIntire 4e6c87eca2
feat(telemetry): broaden Instrument span coverage
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.
2026-04-18 17:25:33 -05:00

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