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.
This commit is contained in:
Graham McIntire 2026-04-18 17:25:32 -05:00
parent d9ffda43d3
commit 4e6c87eca2
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
17 changed files with 414 additions and 155 deletions

View file

@ -7,20 +7,25 @@ defmodule Microwaveprop.Geocoder do
@spec geocode(String.t()) :: {:ok, %{lat: float(), lon: float()}} | {:error, String.t()} @spec geocode(String.t()) :: {:ok, %{lat: float(), lon: float()}} | {:error, String.t()}
def geocode(address) do def geocode(address) do
case Req.get(client(), url: "/maps/api/geocode/json", params: [address: address, key: api_key()]) do Microwaveprop.Instrument.span([:geocoder, :geocode], %{}, fn ->
{:ok, %{status: 200, body: %{"status" => "OK", "results" => [first | _]}}} -> case Req.get(client(),
%{"geometry" => %{"location" => %{"lat" => lat, "lng" => lon}}} = first url: "/maps/api/geocode/json",
{:ok, %{lat: lat, lon: lon}} params: [address: address, key: api_key()]
) do
{:ok, %{status: 200, body: %{"status" => "OK", "results" => [first | _]}}} ->
%{"geometry" => %{"location" => %{"lat" => lat, "lng" => lon}}} = first
{:ok, %{lat: lat, lon: lon}}
{:ok, %{status: 200, body: %{"status" => "ZERO_RESULTS"}}} -> {:ok, %{status: 200, body: %{"status" => "ZERO_RESULTS"}}} ->
{:error, "No results found"} {:error, "No results found"}
{:ok, %{status: 200, body: %{"status" => status}}} -> {:ok, %{status: 200, body: %{"status" => status}}} ->
{:error, "Geocoding failed: #{status}"} {:error, "Geocoding failed: #{status}"}
{:error, reason} -> {:error, reason} ->
{:error, "Geocoding request failed: #{inspect(reason)}"} {:error, "Geocoding request failed: #{inspect(reason)}"}
end end
end)
end end
defp client do defp client do

View file

@ -53,18 +53,20 @@ defmodule Microwaveprop.Ionosphere.GiroClient do
@spec fetch(String.t(), DateTime.t(), DateTime.t()) :: @spec fetch(String.t(), DateTime.t(), DateTime.t()) ::
{:ok, [observation()]} | {:error, term()} {:ok, [observation()]} | {:error, term()}
def fetch(ursi_code, %DateTime{} = from_dt, %DateTime{} = to_dt) do def fetch(ursi_code, %DateTime{} = from_dt, %DateTime{} = to_dt) do
url = build_url(ursi_code, from_dt, to_dt) Microwaveprop.Instrument.span([:giro, :fetch], %{station: ursi_code}, fn ->
url = build_url(ursi_code, from_dt, to_dt)
case Req.get(url, [receive_timeout: @default_timeout_ms] ++ req_options()) do case Req.get(url, [receive_timeout: @default_timeout_ms] ++ req_options()) do
{:ok, %{status: 200, body: body}} when is_binary(body) -> {:ok, %{status: 200, body: body}} when is_binary(body) ->
parse_tabular(body) parse_tabular(body)
{:ok, %{status: status, body: body}} -> {:ok, %{status: status, body: body}} ->
{:error, "GIRO HTTP #{status}: #{inspect(body)}"} {:error, "GIRO HTTP #{status}: #{inspect(body)}"}
{:error, reason} -> {:error, reason} ->
{:error, "GIRO request failed: #{inspect(reason)}"} {:error, "GIRO request failed: #{inspect(reason)}"}
end end
end)
end end
@doc """ @doc """

View file

@ -13,8 +13,13 @@ defmodule Microwaveprop.PromEx.InstrumentPlugin do
def event_metrics(_opts) do def event_metrics(_opts) do
[ [
http_client_events(), http_client_events(),
more_http_client_events(),
subprocess_events(),
db_batch_events(), db_batch_events(),
worker_phase_events(), worker_phase_events(),
top_level_worker_events(),
liveview_hot_path_events(),
cache_hit_events(),
oban_queue_depth_event() oban_queue_depth_event()
] ]
end end
@ -96,6 +101,183 @@ defmodule Microwaveprop.PromEx.InstrumentPlugin do
) )
end end
defp more_http_client_events do
Event.build(
:microwaveprop_more_http_client_events,
[
distribution("microwaveprop.iem.fetch_iemre.duration.milliseconds",
event_name: [:microwaveprop, :iem, :fetch_iemre, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.mrms.list_latest.duration.milliseconds",
event_name: [:microwaveprop, :mrms, :list_latest, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.mrms.download.duration.milliseconds",
event_name: [:microwaveprop, :mrms, :download, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.rtma.fetch_observation.duration.milliseconds",
event_name: [:microwaveprop, :rtma, :fetch_observation, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.ncei.fetch_metar.duration.milliseconds",
event_name: [:microwaveprop, :ncei, :fetch_metar, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.solar.fetch_indices.duration.milliseconds",
event_name: [:microwaveprop, :solar, :fetch_indices, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.swpc.fetch.duration.milliseconds",
event_name: [:microwaveprop, :swpc, :fetch, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.giro.fetch.duration.milliseconds",
event_name: [:microwaveprop, :giro, :fetch, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.qrz.request.duration.milliseconds",
event_name: [:microwaveprop, :qrz, :request, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.geocoder.geocode.duration.milliseconds",
event_name: [:microwaveprop, :geocoder, :geocode, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.srtm.download_tile.duration.milliseconds",
event_name: [:microwaveprop, :srtm, :download_tile, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.hrrr.download_grib_ranges.duration.milliseconds",
event_name: [:microwaveprop, :hrrr, :download_grib_ranges, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
)
]
)
end
defp subprocess_events do
Event.build(
:microwaveprop_subprocess_events,
[
distribution("microwaveprop.wgrib2.extract_grid.duration.milliseconds",
event_name: [:microwaveprop, :wgrib2, :extract_grid, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: long_buckets()]
),
distribution("microwaveprop.wgrib2.extract_grid_from_file.duration.milliseconds",
event_name: [:microwaveprop, :wgrib2, :extract_grid_from_file, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: long_buckets()]
),
distribution("microwaveprop.wgrib2.extract_grid_from_file_mapped.duration.milliseconds",
event_name: [:microwaveprop, :wgrib2, :extract_grid_from_file_mapped, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: long_buckets()]
)
]
)
end
defp top_level_worker_events do
Event.build(
:microwaveprop_top_level_worker_events,
[
distribution("microwaveprop.worker.terrain_profile.duration.milliseconds",
event_name: [:microwaveprop, :worker, :terrain_profile, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.worker.mechanism_classify.duration.milliseconds",
event_name: [:microwaveprop, :worker, :mechanism_classify, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.worker.mrms_fetch.duration.milliseconds",
event_name: [:microwaveprop, :worker, :mrms_fetch, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: long_buckets()]
)
]
)
end
defp liveview_hot_path_events do
Event.build(
:microwaveprop_liveview_hot_path_events,
[
distribution("microwaveprop.propagation.scores_at.duration.milliseconds",
event_name: [:microwaveprop, :propagation, :scores_at, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.propagation.point_forecast.duration.milliseconds",
event_name: [:microwaveprop, :propagation, :point_forecast, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.propagation.point_detail.duration.milliseconds",
event_name: [:microwaveprop, :propagation, :point_detail, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
),
distribution("microwaveprop.propagation.daily_outlook_at.duration.milliseconds",
event_name: [:microwaveprop, :propagation, :daily_outlook_at, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: default_buckets()]
)
]
)
end
defp cache_hit_events do
Event.build(
:microwaveprop_cache_hit_events,
[
counter("microwaveprop.propagation.scores_at.cache.count",
event_name: [:microwaveprop, :propagation, :scores_at, :cache],
tags: [:hit],
description: "ScoreCache hit/miss for the map's scores_at fetch"
)
]
)
end
defp db_batch_events do defp db_batch_events do
Event.build( Event.build(
:microwaveprop_db_batch_events, :microwaveprop_db_batch_events,

View file

@ -249,23 +249,24 @@ defmodule Microwaveprop.Propagation do
@spec scores_at(non_neg_integer(), DateTime.t() | nil, map() | nil) :: @spec scores_at(non_neg_integer(), DateTime.t() | nil, map() | nil) ::
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}] [%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]
def scores_at(band_mhz, valid_time, bounds \\ nil) do def scores_at(band_mhz, valid_time, bounds \\ nil) do
time = valid_time || earliest_valid_time(band_mhz) Microwaveprop.Instrument.span([:propagation, :scores_at], %{band_mhz: band_mhz}, fn ->
time = valid_time || earliest_valid_time(band_mhz)
case time do case time do
nil -> nil -> []
[] _ -> scores_at_fetch(band_mhz, time, bounds)
end
_ -> end)
scores_at_fetch(band_mhz, time, bounds)
end
end end
defp scores_at_fetch(band_mhz, time, bounds) do defp scores_at_fetch(band_mhz, time, bounds) do
case ScoreCache.fetch_bounds(band_mhz, time, bounds) do case ScoreCache.fetch_bounds(band_mhz, time, bounds) do
{:ok, scores} -> {:ok, scores} ->
:telemetry.execute([:microwaveprop, :propagation, :scores_at, :cache], %{}, %{hit: true})
Enum.map(scores, &Map.put(&1, :valid_time, time)) Enum.map(scores, &Map.put(&1, :valid_time, time))
:miss -> :miss ->
:telemetry.execute([:microwaveprop, :propagation, :scores_at, :cache], %{}, %{hit: false})
read_from_disk_and_cache(band_mhz, time, bounds) read_from_disk_and_cache(band_mhz, time, bounds)
end end
end end
@ -341,34 +342,40 @@ defmodule Microwaveprop.Propagation do
@spec daily_outlook_at(float(), float(), keyword()) :: @spec daily_outlook_at(float(), float(), keyword()) ::
[%{date: Date.t(), peak_score: non_neg_integer()}] [%{date: Date.t(), peak_score: non_neg_integer()}]
def daily_outlook_at(lat, lon, opts \\ []) do def daily_outlook_at(lat, lon, opts \\ []) do
band_mhz = Keyword.get(opts, :band_mhz, 10_000) Microwaveprop.Instrument.span([:propagation, :daily_outlook_at], %{}, fn ->
days = Keyword.get(opts, :days, 7) band_mhz = Keyword.get(opts, :band_mhz, 10_000)
today = DateTime.to_date(DateTime.utc_now()) days = Keyword.get(opts, :days, 7)
horizon = Date.add(today, days - 1) today = DateTime.to_date(DateTime.utc_now())
horizon = Date.add(today, days - 1)
band_mhz band_mhz
|> point_forecast(lat, lon) |> point_forecast(lat, lon)
|> Enum.group_by(fn entry -> |> Enum.group_by(fn entry ->
DateTime.to_date(entry.valid_time) DateTime.to_date(entry.valid_time)
end)
|> Enum.filter(fn {date, _} ->
Date.compare(date, today) != :lt and Date.compare(date, horizon) != :gt
end)
|> Enum.map(fn {date, entries} ->
%{date: date, peak_score: entries |> Enum.map(& &1.score) |> Enum.max()}
end)
|> Enum.sort_by(& &1.date, Date)
end) end)
|> Enum.filter(fn {date, _} -> Date.compare(date, today) != :lt and Date.compare(date, horizon) != :gt end)
|> Enum.map(fn {date, entries} ->
%{date: date, peak_score: entries |> Enum.map(& &1.score) |> Enum.max()}
end)
|> Enum.sort_by(& &1.date, Date)
end end
@doc "Get scores across all forecast hours for a single grid point (for sparkline)." @doc "Get scores across all forecast hours for a single grid point (for sparkline)."
@spec point_forecast(non_neg_integer(), float(), float()) :: @spec point_forecast(non_neg_integer(), float(), float()) ::
[%{valid_time: DateTime.t(), score: non_neg_integer()}] [%{valid_time: DateTime.t(), score: non_neg_integer()}]
def point_forecast(band_mhz, lat, lon) do def point_forecast(band_mhz, lat, lon) do
{snapped_lat, snapped_lon} = snap_to_grid(lat, lon) Microwaveprop.Instrument.span([:propagation, :point_forecast], %{band_mhz: band_mhz}, fn ->
now = DateTime.utc_now() {snapped_lat, snapped_lon} = snap_to_grid(lat, lon)
now = DateTime.utc_now()
case ScoreCache.valid_times(band_mhz) do case ScoreCache.valid_times(band_mhz) do
[] -> point_forecast_from_store(band_mhz, snapped_lat, snapped_lon, now) [] -> point_forecast_from_store(band_mhz, snapped_lat, snapped_lon, now)
cached -> point_forecast_from_cache(band_mhz, snapped_lat, snapped_lon, now, cached) cached -> point_forecast_from_cache(band_mhz, snapped_lat, snapped_lon, now, cached)
end end
end)
end end
defp point_forecast_from_store(band_mhz, lat, lon, now) do defp point_forecast_from_store(band_mhz, lat, lon, now) do
@ -428,6 +435,12 @@ defmodule Microwaveprop.Propagation do
} }
| nil | nil
def point_detail(band_mhz, lat, lon, valid_time \\ nil) do def point_detail(band_mhz, lat, lon, valid_time \\ nil) do
Microwaveprop.Instrument.span([:propagation, :point_detail], %{band_mhz: band_mhz}, fn ->
do_point_detail(band_mhz, lat, lon, valid_time)
end)
end
defp do_point_detail(band_mhz, lat, lon, valid_time) do
{snapped_lat, snapped_lon} = snap_to_grid(lat, lon) {snapped_lat, snapped_lon} = snap_to_grid(lat, lon)
time = valid_time || latest_valid_time(band_mhz) time = valid_time || latest_valid_time(band_mhz)

View file

@ -82,23 +82,25 @@ defmodule Microwaveprop.Qrz.Client do
defp handle_lookup_result({:error, _} = error, _, _), do: error defp handle_lookup_result({:error, _} = error, _, _), do: error
defp do_request(params) do defp do_request(params) do
config = Application.get_env(:microwaveprop, __MODULE__, []) Microwaveprop.Instrument.span([:qrz, :request], %{}, fn ->
config = Application.get_env(:microwaveprop, __MODULE__, [])
req_opts = req_opts =
[url: @url, params: params] [url: @url, params: params]
|> maybe_add_plug(config) |> maybe_add_plug(config)
|> maybe_add_retry(config) |> maybe_add_retry(config)
case Req.get(req_opts) do case Req.get(req_opts) do
{:ok, %{status: 200, body: body}} -> {:ok, %{status: 200, body: body}} ->
{:ok, body} {:ok, body}
{:ok, %{status: status}} -> {:ok, %{status: status}} ->
{:error, "HTTP #{status}"} {:error, "HTTP #{status}"}
{:error, reason} -> {:error, reason} ->
{:error, "Request failed: #{inspect(reason)}"} {:error, "Request failed: #{inspect(reason)}"}
end end
end)
end end
defp maybe_add_plug(opts, config) do defp maybe_add_plug(opts, config) do

View file

@ -150,18 +150,20 @@ defmodule Microwaveprop.SpaceWeather.SwpcClient do
# ---------- Shared HTTP / parsing helpers ---------- # ---------- Shared HTTP / parsing helpers ----------
defp fetch_and_parse(path, parser) do defp fetch_and_parse(path, parser) do
url = @base_url <> path Microwaveprop.Instrument.span([:swpc, :fetch], %{path: path}, fn ->
url = @base_url <> path
case Req.get(url, [receive_timeout: @default_timeout_ms] ++ req_options()) do case Req.get(url, [receive_timeout: @default_timeout_ms] ++ req_options()) do
{:ok, %{status: 200, body: body}} -> {:ok, %{status: 200, body: body}} ->
parser.(body) parser.(body)
{:ok, %{status: status, body: body}} -> {:ok, %{status: status, body: body}} ->
{:error, "SWPC HTTP #{status}: #{inspect(body)}"} {:error, "SWPC HTTP #{status}: #{inspect(body)}"}
{:error, reason} -> {:error, reason} ->
{:error, "SWPC request failed: #{inspect(reason)}"} {:error, "SWPC request failed: #{inspect(reason)}"}
end end
end)
end end
defp decode_array(body) when is_binary(body) do defp decode_array(body) when is_binary(body) do

View file

@ -23,6 +23,12 @@ defmodule Microwaveprop.Terrain.Srtm do
@spec download_tile(float(), float(), String.t()) :: {:ok, String.t()} | {:error, term()} @spec download_tile(float(), float(), String.t()) :: {:ok, String.t()} | {:error, term()}
def download_tile(lat, lon, tiles_dir) do def download_tile(lat, lon, tiles_dir) do
Microwaveprop.Instrument.span([:srtm, :download_tile], %{lat: lat, lon: lon}, fn ->
do_download_tile(lat, lon, tiles_dir)
end)
end
defp do_download_tile(lat, lon, tiles_dir) do
filename = tile_filename(lat, lon) filename = tile_filename(lat, lon)
lat_dir = String.slice(filename, 0, 3) lat_dir = String.slice(filename, 0, 3)
url = "#{@base_url}/#{lat_dir}/#{filename}.gz" url = "#{@base_url}/#{lat_dir}/#{filename}.gz"

View file

@ -32,11 +32,17 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
""" """
@spec extract_grid(binary(), String.t(), grid_spec()) :: {:ok, point_grid()} | {:error, term()} @spec extract_grid(binary(), String.t(), grid_spec()) :: {:ok, point_grid()} | {:error, term()}
def extract_grid(grib_binary, match_pattern, grid_spec) do def extract_grid(grib_binary, match_pattern, grid_spec) do
if available?() do Microwaveprop.Instrument.span(
extract_with_wgrib2(grib_binary, match_pattern, grid_spec) [:wgrib2, :extract_grid],
else %{bytes: byte_size(grib_binary)},
{:error, :wgrib2_not_available} fn ->
end if available?() do
extract_with_wgrib2(grib_binary, match_pattern, grid_spec)
else
{:error, :wgrib2_not_available}
end
end
)
end end
@doc """ @doc """
@ -47,11 +53,13 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
@spec extract_grid_from_file(Path.t(), String.t(), grid_spec()) :: @spec extract_grid_from_file(Path.t(), String.t(), grid_spec()) ::
{:ok, point_grid()} | {:error, term()} {:ok, point_grid()} | {:error, term()}
def extract_grid_from_file(grib_path, match_pattern, grid_spec) do def extract_grid_from_file(grib_path, match_pattern, grid_spec) do
if available?() do Microwaveprop.Instrument.span([:wgrib2, :extract_grid_from_file], %{}, fn ->
extract_file_with_wgrib2(grib_path, match_pattern, grid_spec) if available?() do
else extract_file_with_wgrib2(grib_path, match_pattern, grid_spec)
{:error, :wgrib2_not_available} else
end {:error, :wgrib2_not_available}
end
end)
end end
@doc """ @doc """
@ -73,11 +81,13 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
(%{String.t() => float()} -> term()) (%{String.t() => float()} -> term())
) :: {:ok, %{{float(), float()} => term()}} | {:error, term()} ) :: {:ok, %{{float(), float()} => term()}} | {:error, term()}
def extract_grid_from_file_mapped(grib_path, match_pattern, grid_spec, cell_reducer) do def extract_grid_from_file_mapped(grib_path, match_pattern, grid_spec, cell_reducer) do
if available?() do Microwaveprop.Instrument.span([:wgrib2, :extract_grid_from_file_mapped], %{}, fn ->
extract_file_mapped_with_wgrib2(grib_path, match_pattern, grid_spec, cell_reducer) if available?() do
else extract_file_mapped_with_wgrib2(grib_path, match_pattern, grid_spec, cell_reducer)
{:error, :wgrib2_not_available} else
end {:error, :wgrib2_not_available}
end
end)
end end
@doc "Check if wgrib2 is available on the system." @doc "Check if wgrib2 is available on the system."

View file

@ -417,7 +417,14 @@ defmodule Microwaveprop.Weather.HrrrClient do
def download_grib_ranges(_url, []), do: {:ok, <<>>} def download_grib_ranges(_url, []), do: {:ok, <<>>}
def download_grib_ranges(url, ranges) do def download_grib_ranges(url, ranges) do
# Check local cache first (dev only) Microwaveprop.Instrument.span(
[:hrrr, :download_grib_ranges],
%{range_count: length(ranges)},
fn -> do_download_grib_ranges(url, ranges) end
)
end
defp do_download_grib_ranges(url, ranges) do
cache_key = url_to_cache_key(url, ranges) cache_key = url_to_cache_key(url, ranges)
case read_cache(cache_key) do case read_cache(cache_key) do
@ -426,14 +433,18 @@ defmodule Microwaveprop.Weather.HrrrClient do
{:ok, binary} {:ok, binary}
:miss -> :miss ->
case download_grib_ranges_remote(url, ranges) do download_and_cache_grib_ranges(url, ranges, cache_key)
{:ok, binary} = ok -> end
write_cache(cache_key, binary) end
ok
error -> defp download_and_cache_grib_ranges(url, ranges, cache_key) do
error case download_grib_ranges_remote(url, ranges) do
end {:ok, binary} = ok ->
write_cache(cache_key, binary)
ok
error ->
error
end end
end end

View file

@ -90,18 +90,20 @@ defmodule Microwaveprop.Weather.IemClient do
@spec fetch_iemre(float(), float(), Date.t()) :: {:ok, [map()]} | {:error, term()} @spec fetch_iemre(float(), float(), Date.t()) :: {:ok, [map()]} | {:error, term()}
def fetch_iemre(lat, lon, date) do def fetch_iemre(lat, lon, date) do
url = iemre_url(lat, lon, date) Microwaveprop.Instrument.span([:iem, :fetch_iemre], %{}, fn ->
url = iemre_url(lat, lon, date)
case Req.get(url, req_options()) do case Req.get(url, req_options()) do
{:ok, %{status: 200, body: body}} -> {:ok, %{status: 200, body: body}} ->
{:ok, parse_iemre_json(body)} {:ok, parse_iemre_json(body)}
{:ok, %{status: status}} -> {:ok, %{status: status}} ->
{:error, "IEM IEMRE HTTP #{status}"} {:error, "IEM IEMRE HTTP #{status}"}
{:error, reason} -> {:error, reason} ->
{:error, reason} {:error, reason}
end end
end)
end end
defp req_options do defp req_options do

View file

@ -29,6 +29,10 @@ defmodule Microwaveprop.Weather.MrmsClient do
""" """
@spec list_latest(non_neg_integer()) :: {:ok, [listing_entry()]} | {:error, term()} @spec list_latest(non_neg_integer()) :: {:ok, [listing_entry()]} | {:error, term()}
def list_latest(limit \\ 10) do def list_latest(limit \\ 10) do
Microwaveprop.Instrument.span([:mrms, :list_latest], %{}, fn -> do_list_latest(limit) end)
end
defp do_list_latest(limit) do
case Req.get(@base_url, req_options()) do case Req.get(@base_url, req_options()) do
{:ok, %{status: 200, body: body}} -> {:ok, %{status: 200, body: body}} ->
entries = entries =
@ -130,16 +134,18 @@ defmodule Microwaveprop.Weather.MrmsClient do
# the URL ends in .gz. No gunzip step needed. # the URL ends in .gz. No gunzip step needed.
url = @base_url <> filename url = @base_url <> filename
case Req.get(url, req_options()) do Microwaveprop.Instrument.span([:mrms, :download], %{}, fn ->
{:ok, %{status: 200, body: body}} when is_binary(body) -> case Req.get(url, req_options()) do
{:ok, body} {:ok, %{status: 200, body: body}} when is_binary(body) ->
{:ok, body}
{:ok, %{status: status}} -> {:ok, %{status: status}} ->
{:error, {:http_status, status}} {:error, {:http_status, status}}
{:error, reason} -> {:error, reason} ->
{:error, reason} {:error, reason}
end end
end)
end end
defp write_temp(grib_bytes) do defp write_temp(grib_bytes) do

View file

@ -23,19 +23,21 @@ defmodule Microwaveprop.Weather.NceiMetarClient do
""" """
@spec fetch(String.t(), pos_integer(), pos_integer()) :: {:ok, [map()]} | {:error, term()} @spec fetch(String.t(), pos_integer(), pos_integer()) :: {:ok, [map()]} | {:error, term()}
def fetch(icao, year, month) do def fetch(icao, year, month) do
month_str = month |> Integer.to_string() |> String.pad_leading(2, "0") Microwaveprop.Instrument.span([:ncei, :fetch_metar], %{station: icao}, fn ->
url = "#{@base_url}/#{year}/#{month_str}/asos-5min-#{icao}-#{year}#{month_str}.dat" month_str = month |> Integer.to_string() |> String.pad_leading(2, "0")
url = "#{@base_url}/#{year}/#{month_str}/asos-5min-#{icao}-#{year}#{month_str}.dat"
case Req.get(url, receive_timeout: 60_000) do case Req.get(url, receive_timeout: 60_000) do
{:ok, %{status: 200, body: body}} -> {:ok, %{status: 200, body: body}} ->
{:ok, parse(body)} {:ok, parse(body)}
{:ok, %{status: status}} -> {:ok, %{status: status}} ->
{:error, "NCEI 5-min HTTP #{status}"} {:error, "NCEI 5-min HTTP #{status}"}
{:error, reason} -> {:error, reason} ->
{:error, reason} {:error, reason}
end end
end)
end end
@doc """ @doc """

View file

@ -35,6 +35,14 @@ defmodule Microwaveprop.Weather.RtmaClient do
""" """
@spec fetch_observation(float(), float(), DateTime.t()) :: {:ok, map()} | {:error, term()} @spec fetch_observation(float(), float(), DateTime.t()) :: {:ok, map()} | {:error, term()}
def fetch_observation(lat, lon, timestamp) do def fetch_observation(lat, lon, timestamp) do
Microwaveprop.Instrument.span(
[:rtma, :fetch_observation],
%{},
fn -> do_fetch_observation(lat, lon, timestamp) end
)
end
defp do_fetch_observation(lat, lon, timestamp) do
# RTMA runs every hour with 15-min updates; round to nearest hour # RTMA runs every hour with 15-min updates; round to nearest hour
valid_time = %{DateTime.truncate(timestamp, :second) | minute: 0, second: 0} valid_time = %{DateTime.truncate(timestamp, :second) | minute: 0, second: 0}
rlat = Float.round(lat * 40) / 40 rlat = Float.round(lat * 40) / 40

View file

@ -8,18 +8,20 @@ defmodule Microwaveprop.Weather.SolarClient do
@spec fetch_solar_indices() :: {:ok, [map()]} | {:error, term()} @spec fetch_solar_indices() :: {:ok, [map()]} | {:error, term()}
def fetch_solar_indices do def fetch_solar_indices do
req_options = Application.get_env(:microwaveprop, :solar_req_options, []) Microwaveprop.Instrument.span([:solar, :fetch_indices], %{}, fn ->
req_options = Application.get_env(:microwaveprop, :solar_req_options, [])
case Req.get(@gfz_url, [receive_timeout: 120_000, retry: false] ++ req_options) do case Req.get(@gfz_url, [receive_timeout: 120_000, retry: false] ++ req_options) do
{:ok, %{status: 200, body: body}} -> {:ok, %{status: 200, body: body}} ->
{:ok, parse_gfz_file(body)} {:ok, parse_gfz_file(body)}
{:ok, %{status: status}} -> {:ok, %{status: status}} ->
{:error, "HTTP #{status}"} {:error, "HTTP #{status}"}
{:error, reason} -> {:error, reason} ->
{:error, reason} {:error, reason}
end end
end)
end end
@spec filter_since([map()], Date.t()) :: [map()] @spec filter_since([map()], Date.t()) :: [map()]

View file

@ -42,17 +42,19 @@ defmodule Microwaveprop.Workers.MechanismClassifyWorker do
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{args: %{"contact_id" => contact_id}}) do def perform(%Oban.Job{args: %{"contact_id" => contact_id}}) do
case Repo.get(Contact, contact_id) do Microwaveprop.Instrument.span([:worker, :mechanism_classify], %{contact_id: contact_id}, fn ->
nil -> case Repo.get(Contact, contact_id) do
:ok nil ->
:ok
%Contact{pos1: p1, pos2: p2} = contact when is_map(p1) and is_map(p2) -> %Contact{pos1: p1, pos2: p2} = contact when is_map(p1) and is_map(p2) ->
classify_and_persist(contact) classify_and_persist(contact)
contact -> contact ->
mark_status(contact, :unavailable, nil, nil) mark_status(contact, :unavailable, nil, nil)
:ok :ok
end end
end)
end end
defp classify_and_persist(%Contact{} = contact) do defp classify_and_persist(%Contact{} = contact) do

View file

@ -17,22 +17,24 @@ defmodule Microwaveprop.Workers.MrmsFetchWorker do
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{}) do def perform(%Oban.Job{}) do
case MrmsClient.fetch_latest(MrmsCache.valid_time()) do Microwaveprop.Instrument.span([:worker, :mrms_fetch], %{}, fn ->
{:ok, valid_time, grid} -> case MrmsClient.fetch_latest(MrmsCache.valid_time()) do
MrmsCache.broadcast_put(valid_time, grid) {:ok, valid_time, grid} ->
MrmsCache.broadcast_put(valid_time, grid)
Logger.info("MrmsFetch: cached #{map_size(grid)} cells at #{valid_time} (#{count_rainy(grid)} with rain)") Logger.info("MrmsFetch: cached #{map_size(grid)} cells at #{valid_time} (#{count_rainy(grid)} with rain)")
:ok :ok
{:up_to_date, valid_time} -> {:up_to_date, valid_time} ->
Logger.debug("MrmsFetch: cache already has #{valid_time}") Logger.debug("MrmsFetch: cache already has #{valid_time}")
:ok :ok
{:error, reason} -> {:error, reason} ->
Logger.warning("MrmsFetch: skipped — #{inspect(reason)}") Logger.warning("MrmsFetch: skipped — #{inspect(reason)}")
:ok :ok
end end
end)
end end
defp count_rainy(grid) do defp count_rainy(grid) do

View file

@ -18,12 +18,14 @@ defmodule Microwaveprop.Workers.TerrainProfileWorker do
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{args: %{"contact_id" => contact_id}}) do def perform(%Oban.Job{args: %{"contact_id" => contact_id}}) do
if Terrain.has_terrain_profile?(contact_id) do Microwaveprop.Instrument.span([:worker, :terrain_profile], %{contact_id: contact_id}, fn ->
Radio.set_enrichment_status!([contact_id], :terrain_status, :complete) if Terrain.has_terrain_profile?(contact_id) do
:ok Radio.set_enrichment_status!([contact_id], :terrain_status, :complete)
else :ok
analyse_terrain(contact_id) else
end analyse_terrain(contact_id)
end
end)
end end
defp analyse_terrain(contact_id) do defp analyse_terrain(contact_id) do