diff --git a/lib/microwaveprop/accounts/user_token.ex b/lib/microwaveprop/accounts/user_token.ex index fbf76f31..a356686c 100644 --- a/lib/microwaveprop/accounts/user_token.ex +++ b/lib/microwaveprop/accounts/user_token.ex @@ -4,6 +4,7 @@ defmodule Microwaveprop.Accounts.UserToken do import Ecto.Query + alias Microwaveprop.Accounts.User alias Microwaveprop.Accounts.UserToken @hash_algorithm :sha256 @@ -24,11 +25,22 @@ defmodule Microwaveprop.Accounts.UserToken do field :context, :string field :sent_to, :string field :authenticated_at, :utc_datetime - belongs_to :user, Microwaveprop.Accounts.User + belongs_to :user, User timestamps(type: :utc_datetime, updated_at: false) end + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + token: binary() | nil, + context: String.t() | nil, + sent_to: String.t() | nil, + authenticated_at: DateTime.t() | nil, + user: User.t() | Ecto.Association.NotLoaded.t() | nil, + user_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil + } + @doc """ Generates a token that will be stored in a signed place, such as session or cookie. As they are signed, those diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index f4fed9dd..b0683c38 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -172,29 +172,35 @@ defmodule Microwaveprop.Propagation do """ @spec replace_scores(Enumerable.t(), DateTime.t()) :: {:ok, non_neg_integer()} | {:error, term()} def replace_scores(scores, %DateTime{} = valid_time) do - Microwaveprop.Instrument.span( - [:db, :replace_scores], - %{valid_time: valid_time}, - fn -> do_replace_scores(scores, valid_time) end - ) + do_replace_scores(scores, valid_time) end defp do_replace_scores(scores, valid_time) do + # Pure grouping phase runs outside the telemetry span — typically + # <10ms on small result sets, and the span's two dispatches + # (~100µs each) would otherwise dominate. The span now wraps only + # the per-band writes, which is where the actual DB cost lives. {per_band, total} = Enum.reduce(scores, {%{}, 0}, fn score, {acc, count} -> {Map.update(acc, score.band_mhz, [score], &[score | &1]), count + 1} end) - Enum.each(per_band, fn {band_mhz, band_scores} -> - try do - ScoresFile.write!(band_mhz, valid_time, band_scores) - rescue - e -> - Logger.warning("Propagation: ScoresFile write failed for band=#{band_mhz} vt=#{valid_time}: #{inspect(e)}") - end - end) + Microwaveprop.Instrument.span( + [:db, :replace_scores], + %{valid_time: valid_time}, + fn -> + Enum.each(per_band, fn {band_mhz, band_scores} -> + try do + ScoresFile.write!(band_mhz, valid_time, band_scores) + rescue + e -> + Logger.warning("Propagation: ScoresFile write failed for band=#{band_mhz} vt=#{valid_time}: #{inspect(e)}") + end + end) - {:ok, total} + {:ok, total} + end + ) end @doc """ diff --git a/lib/microwaveprop/propagation/scorer.ex b/lib/microwaveprop/propagation/scorer.ex index 8b0ba30f..2eb8030b 100644 --- a/lib/microwaveprop/propagation/scorer.ex +++ b/lib/microwaveprop/propagation/scorer.ex @@ -12,6 +12,11 @@ defmodule Microwaveprop.Propagation.Scorer do # ── Temperature conversion helpers ──────────────────────────────── alias Microwaveprop.Propagation.Region + # Compile-time inverse of the Marshall-Palmer b exponent (1/1.6). + # Pre-computed to avoid one float division per rain pixel in the + # dBZ → rain-rate hot path. + @mp_inv_b 1 / 1.6 + @doc "Converts Fahrenheit to Celsius. Returns nil for nil input." @spec f_to_c(number() | nil) :: float() | nil def f_to_c(nil), do: nil @@ -67,7 +72,7 @@ defmodule Microwaveprop.Propagation.Scorer do def dbz_to_rain_rate_mmhr(dbz) do z = :math.pow(10, dbz / 10) - r = :math.pow(z / 200, 1 / 1.6) + r = :math.pow(z / 200, @mp_inv_b) min(r, 150.0) end @@ -522,12 +527,22 @@ defmodule Microwaveprop.Propagation.Scorer do """ @spec composite_score(map(), map()) :: %{score: integer(), factors: map()} def composite_score(conditions, band_config) do - tod_score = conditions[:tod_score] || band_invariant_tod(conditions) - sky_score = conditions[:sky_score] || score_sky(conditions.sky_cover_pct) - wind_score = conditions[:wind_score] || score_wind(conditions.wind_speed_kts) - - pressure_score = - conditions[:pressure_score] || score_pressure(conditions.pressure_mb, conditions.prev_pressure_mb) + # Contract: band invariants are either all precomputed (via + # `precompute_band_invariants/1`, typically merged in by the grid + # scorer for per-point reuse across bands) or all absent. We check + # one key and derive the rest from the same branch so we never + # silently mix cached and freshly-computed values. + %{ + tod_score: tod_score, + sky_score: sky_score, + wind_score: wind_score, + pressure_score: pressure_score + } = + if Map.has_key?(conditions, :tod_score) do + conditions + else + precompute_band_invariants(conditions) + end factors = %{ humidity: score_humidity(conditions.abs_humidity, band_config), @@ -559,13 +574,6 @@ defmodule Microwaveprop.Propagation.Scorer do %{score: round(weighted_sum), factors: factors} end - defp band_invariant_tod(conditions) do - {s, _} = - score_time_of_day(conditions.utc_hour, conditions.utc_minute, conditions.month, conditions.longitude) - - s - end - @doc """ Merges multiple HRRR profiles along a path into a single conditions map. diff --git a/lib/microwaveprop/weather/hrrr_climatology.ex b/lib/microwaveprop/weather/hrrr_climatology.ex index 9cae7949..5f037ed5 100644 --- a/lib/microwaveprop/weather/hrrr_climatology.ex +++ b/lib/microwaveprop/weather/hrrr_climatology.ex @@ -27,7 +27,18 @@ defmodule Microwaveprop.Weather.HrrrClimatology do field :sample_count, :integer end - @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + lat: float() | nil, + lon: float() | nil, + month: 1..12 | nil, + hour: 0..23 | nil, + mean_surface_temp_c: float() | nil, + stddev_surface_temp_c: float() | nil, + sample_count: non_neg_integer() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() def changeset(record, attrs) do record |> cast(attrs, [:lat, :lon, :month, :hour, :mean_surface_temp_c, :stddev_surface_temp_c, :sample_count]) diff --git a/lib/microwaveprop_web/endpoint.ex b/lib/microwaveprop_web/endpoint.ex index 3a98a773..140aa54e 100644 --- a/lib/microwaveprop_web/endpoint.ex +++ b/lib/microwaveprop_web/endpoint.ex @@ -59,8 +59,17 @@ defmodule MicrowavepropWeb.Endpoint do plug MicrowavepropWeb.Router @doc false - def log_level(%{path_info: ["health"]}), do: false - def log_level(%{path_info: ["metrics" | _]}), do: false + # Match on `request_path` rather than `path_info` because `Plug.forward/4` + # (used by `forward "/metrics", MetricsPlug` in the router) rewrites + # `path_info` to the unmatched remainder before dispatching to the + # forwarded plug. `MetricsPlug` calls `send_resp/2` inside that + # dispatch, so `Plug.Telemetry`'s `register_before_send` callback sees + # `path_info: []` / `script_name: ["metrics"]` and a clause keyed on + # `path_info` never matches. `request_path` is set once by the adapter + # and is stable across forward, so it's the reliable discriminator. + def log_level(%{request_path: "/health"}), do: false + def log_level(%{request_path: "/metrics"}), do: false + def log_level(%{request_path: "/metrics/" <> _}), do: false def log_level(%{method: "HEAD"}), do: false def log_level(_conn), do: :info end diff --git a/test/microwaveprop_web/metrics_log_suppression_test.exs b/test/microwaveprop_web/metrics_log_suppression_test.exs new file mode 100644 index 00000000..c13c75ff --- /dev/null +++ b/test/microwaveprop_web/metrics_log_suppression_test.exs @@ -0,0 +1,56 @@ +defmodule MicrowavepropWeb.MetricsLogSuppressionTest do + @moduledoc """ + Regression test for the `/metrics` log-suppression bug. + + `Plug.forward/4` rewrites `conn.path_info` to the remainder after the + matched prefix before dispatching to the forwarded plug. For the + `forward "/metrics", MetricsPlug` route this means `path_info` is `[]` + (and `script_name` is `["metrics"]`) by the time the response is being + sent. Since `send_resp/2` runs inside the forwarded plug, the + `register_before_send` callback installed by `Plug.Telemetry` sees the + rewritten conn and the endpoint's `log_level/1` pattern on + `path_info: ["metrics" | _]` never matches — so a `Sent 200 in Xms` + line is logged on every Prometheus scrape. + """ + + use MicrowavepropWeb.ConnCase, async: false + + import ExUnit.CaptureLog + + setup do + # The project config sets :logger level to :warning in test, which + # would drop the :info-level "Sent 200" line before capture_log ever + # saw it. Bump to :info for this test so the suppression behaviour + # is actually observable, then restore afterwards. + prev_level = Logger.level() + Logger.configure(level: :info) + on_exit(fn -> Logger.configure(level: prev_level) end) + :ok + end + + test "GET /metrics does not emit a Phoenix endpoint Sent log line", %{conn: conn} do + log = + capture_log([level: :info], fn -> + conn = get(conn, "/metrics") + assert conn.status == 200 + end) + + refute log =~ "Sent 200" + end + + test "log_level/1 returns false for the rewritten conn shape produced by forward", %{ + conn: _conn + } do + # When Plug.forward dispatches to the MetricsPlug, the conn it passes + # has path_info stripped (empty) and script_name holding the prefix. + # Plug.Telemetry's before_send sees this exact shape. + rewritten = %{ + path_info: [], + script_name: ["metrics"], + request_path: "/metrics", + method: "GET" + } + + assert MicrowavepropWeb.Endpoint.log_level(rewritten) == false + end +end