From c7bc3ed5d037988251f5a76d2a932cd14d7a7fe5 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 18 Apr 2026 16:39:30 -0500 Subject: [PATCH] feat(telemetry): PromEx Prometheus exporter at /metrics Wires PromEx with its built-in Application / BEAM / Phoenix / Ecto / Oban plugins plus a custom `Microwaveprop.PromEx.InstrumentPlugin` that registers Prometheus histograms for every `Microwaveprop.Instrument` span (HRRR/NEXRAD/IEM/GEFS/NARR/UWYO /Elevation fetches, DB batch upserts, terrain analysis, radar aggregation, propagation score band). Queue-depth gauges from the 10-second poller land at `microwaveprop_oban_queue_count{queue,state}`. Router forwards `/metrics` to MicrowavepropWeb.MetricsPlug which optionally requires a bearer token (`PROMETHEUS_AUTH_TOKEN` env var in runtime.exs); leave it unset to expose metrics publicly and restrict at the ingress / Cloudflare Access layer instead. Example external Prometheus scrape config: scrape_configs: - job_name: microwaveprop scrape_interval: 30s metrics_path: /metrics scheme: https authorization: type: Bearer credentials: static_configs: - targets: ['prop.w5isp.com:443'] --- config/config.exs | 13 ++ config/runtime.exs | 4 + lib/microwaveprop/application.ex | 1 + lib/microwaveprop/prom_ex.ex | 47 +++++ .../prom_ex/instrument_plugin.ex | 174 ++++++++++++++++++ lib/microwaveprop_web/metrics_plug.ex | 51 +++++ lib/microwaveprop_web/router.ex | 8 + mix.exs | 1 + mix.lock | 6 + 9 files changed, 305 insertions(+) create mode 100644 lib/microwaveprop/prom_ex.ex create mode 100644 lib/microwaveprop/prom_ex/instrument_plugin.ex create mode 100644 lib/microwaveprop_web/metrics_plug.ex diff --git a/config/config.exs b/config/config.exs index 01163797..d72cfa72 100644 --- a/config/config.exs +++ b/config/config.exs @@ -41,6 +41,19 @@ config :logger, :default_formatter, # at the `config/runtime.exs`. config :microwaveprop, Microwaveprop.Mailer, adapter: Swoosh.Adapters.Local +# PromEx metrics exporter. We surface the `/metrics` Prometheus scrape +# endpoint via MicrowavepropWeb.Router rather than PromEx's own HTTP +# server, so the router (and optional bearer-token auth in +# MicrowavepropWeb.MetricsPlug) stay the single ingress path. Grafana +# auto-provisioning is off — dashboards live in the dedicated Grafana +# instance and are managed separately. +config :microwaveprop, Microwaveprop.PromEx, + disabled: false, + manual_metrics_start_delay: :no_delay, + drop_metrics_groups: [], + grafana: :disabled, + metrics_server: :disabled + # Configure the endpoint config :microwaveprop, MicrowavepropWeb.Endpoint, url: [host: "localhost"], diff --git a/config/runtime.exs b/config/runtime.exs index 5bb10894..c7bd22e4 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -24,6 +24,10 @@ end config :microwaveprop, MicrowavepropWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))] +if prometheus_token = System.get_env("PROMETHEUS_AUTH_TOKEN") do + config :microwaveprop, :prometheus_auth_token, prometheus_token +end + if qrz_username = System.get_env("QRZ_USERNAME") do config :microwaveprop, Microwaveprop.Qrz.Client, username: qrz_username, diff --git a/lib/microwaveprop/application.ex b/lib/microwaveprop/application.ex index 0f386597..288c210e 100644 --- a/lib/microwaveprop/application.ex +++ b/lib/microwaveprop/application.ex @@ -13,6 +13,7 @@ defmodule Microwaveprop.Application do children = [ MicrowavepropWeb.Telemetry, + Microwaveprop.PromEx, Microwaveprop.Repo, {Cluster.Supervisor, [topologies, [name: Microwaveprop.ClusterSupervisor]]}, {Phoenix.PubSub, name: Microwaveprop.PubSub}, diff --git a/lib/microwaveprop/prom_ex.ex b/lib/microwaveprop/prom_ex.ex new file mode 100644 index 00000000..f83ccc8e --- /dev/null +++ b/lib/microwaveprop/prom_ex.ex @@ -0,0 +1,47 @@ +defmodule Microwaveprop.PromEx do + @moduledoc """ + Prometheus metrics exporter. Combines PromEx's built-in plugins for + Application/BEAM/Phoenix/Ecto/Oban with our own custom metrics + (Microwaveprop.Instrument spans, Oban queue-depth gauges). + + A dedicated Prometheus server outside the Kubernetes cluster scrapes + `/metrics` over the app's normal HTTP endpoint. The `MicrowavepropWeb.Router` + is responsible for making that route reachable. + """ + use PromEx, otp_app: :microwaveprop + + alias PromEx.Plugins + + @impl true + def plugins do + [ + # Defaults: VM memory, process counts, scheduler util, app version. + Plugins.Application, + Plugins.Beam, + + # Phoenix: request duration by route/status, channel/socket events. + {Plugins.Phoenix, router: MicrowavepropWeb.Router, endpoint: MicrowavepropWeb.Endpoint}, + + # Ecto: query duration, queue time, pool size. + {Plugins.Ecto, otp_app: :microwaveprop, repos: [Microwaveprop.Repo]}, + + # Oban: job duration, queue time, state counts, queue depth. + Plugins.Oban, + + # Our own spans: Instrument.span/3 emits [:microwaveprop | suffix] + # events; this plugin converts them to Prometheus histograms. + Microwaveprop.PromEx.InstrumentPlugin + ] + end + + @impl true + def dashboards do + [ + {:prom_ex, "application.json"}, + {:prom_ex, "beam.json"}, + {:prom_ex, "phoenix.json"}, + {:prom_ex, "ecto.json"}, + {:prom_ex, "oban.json"} + ] + end +end diff --git a/lib/microwaveprop/prom_ex/instrument_plugin.ex b/lib/microwaveprop/prom_ex/instrument_plugin.ex new file mode 100644 index 00000000..a5ca08a5 --- /dev/null +++ b/lib/microwaveprop/prom_ex/instrument_plugin.ex @@ -0,0 +1,174 @@ +defmodule Microwaveprop.PromEx.InstrumentPlugin do + @moduledoc """ + PromEx plugin that registers every `Microwaveprop.Instrument` span as + a Prometheus histogram. Keep the catalog in sync with the call sites + in `HrrrClient`, `NexradClient`, `IemClient`, `GefsClient`, + `NarrClient`, `UwyoSoundingClient`, `ElevationClient`, `Weather` batch + upserts, `Propagation.replace_scores`, `PropagationGridWorker`, + `TerrainAnalysis`, and `CommonVolumeRadarWorker`. + """ + use PromEx.Plugin + + @impl true + def event_metrics(_opts) do + [ + http_client_events(), + db_batch_events(), + worker_phase_events(), + oban_queue_depth_event() + ] + end + + # PromEx interprets `Telemetry.Metrics` specs the same way the + # in-process telemetry_metrics module does, so we re-use the same + # event_name / measurement / tags shape from MicrowavepropWeb.Telemetry. + defp http_client_events do + Event.build( + :microwaveprop_http_client_events, + [ + distribution("microwaveprop.hrrr.fetch_grid.duration.milliseconds", + event_name: [:microwaveprop, :hrrr, :fetch_grid, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: default_buckets()] + ), + distribution("microwaveprop.hrrr.fetch_profile.duration.milliseconds", + event_name: [:microwaveprop, :hrrr, :fetch_profile, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: default_buckets()] + ), + distribution("microwaveprop.hrrr.fetch_idx.duration.milliseconds", + event_name: [:microwaveprop, :hrrr, :fetch_idx, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: default_buckets()] + ), + distribution("microwaveprop.nexrad.fetch_frame.duration.milliseconds", + event_name: [:microwaveprop, :nexrad, :fetch_frame, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: default_buckets()] + ), + distribution("microwaveprop.nexrad.decode_png.duration.milliseconds", + event_name: [:microwaveprop, :nexrad, :decode_png, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: default_buckets()] + ), + distribution("microwaveprop.iem.fetch_asos.duration.milliseconds", + event_name: [:microwaveprop, :iem, :fetch_asos, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: default_buckets()] + ), + distribution("microwaveprop.iem.fetch_raob.duration.milliseconds", + event_name: [:microwaveprop, :iem, :fetch_raob, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: default_buckets()] + ), + distribution("microwaveprop.gefs.fetch_grid_profiles.duration.milliseconds", + event_name: [:microwaveprop, :gefs, :fetch_grid_profiles, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: default_buckets()] + ), + distribution("microwaveprop.narr.fetch_profile.duration.milliseconds", + event_name: [:microwaveprop, :narr, :fetch_profile, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: default_buckets()] + ), + distribution("microwaveprop.uwyo.fetch_sounding.duration.milliseconds", + event_name: [:microwaveprop, :uwyo, :fetch_sounding, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: default_buckets()] + ), + distribution("microwaveprop.elevation.fetch_profile.duration.milliseconds", + event_name: [:microwaveprop, :elevation, :fetch_profile, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: default_buckets()] + ) + ] + ) + end + + defp db_batch_events do + Event.build( + :microwaveprop_db_batch_events, + [ + distribution("microwaveprop.db.upsert_hrrr_profiles.duration.milliseconds", + event_name: [:microwaveprop, :db, :upsert_hrrr_profiles, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: db_buckets()] + ), + distribution("microwaveprop.db.upsert_gefs_profiles.duration.milliseconds", + event_name: [:microwaveprop, :db, :upsert_gefs_profiles, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: db_buckets()] + ), + distribution("microwaveprop.db.replace_scores.duration.milliseconds", + event_name: [:microwaveprop, :db, :replace_scores, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: db_buckets()] + ) + ] + ) + end + + defp worker_phase_events do + Event.build( + :microwaveprop_worker_phase_events, + [ + distribution("microwaveprop.propagation_grid.score_band.duration.milliseconds", + event_name: [:microwaveprop, :propagation_grid, :score_band, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: long_buckets()] + ), + distribution("microwaveprop.terrain.analyse.duration.milliseconds", + event_name: [:microwaveprop, :terrain, :analyse, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: default_buckets()] + ), + distribution("microwaveprop.radar.aggregate_stats.duration.milliseconds", + event_name: [:microwaveprop, :radar, :aggregate_stats, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [buckets: default_buckets()] + ) + ] + ) + end + + defp oban_queue_depth_event do + Event.build( + :microwaveprop_oban_queue_depth, + [ + last_value("microwaveprop.oban.queue.count", + event_name: [:microwaveprop, :oban, :queue, :depth], + measurement: :count, + tags: [:queue, :state], + description: "Oban job count per (queue, state) sampled every 10s" + ) + ] + ) + end + + # Buckets in milliseconds. HTTP + compute phases: up to ~30s. + defp default_buckets, do: [10, 50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 30_000] + + # DB batch upserts: can take much longer on cold cache or full + # forecast-hour writes — up to ~2 min. + defp db_buckets, do: [50, 100, 250, 500, 1_000, 2_500, 5_000, 15_000, 60_000, 120_000] + + # Score computation for a full grid-point batch — up to ~5 min. + defp long_buckets, do: [500, 1_000, 5_000, 15_000, 30_000, 60_000, 120_000, 300_000] +end diff --git a/lib/microwaveprop_web/metrics_plug.ex b/lib/microwaveprop_web/metrics_plug.ex new file mode 100644 index 00000000..2aaa96f4 --- /dev/null +++ b/lib/microwaveprop_web/metrics_plug.ex @@ -0,0 +1,51 @@ +defmodule MicrowavepropWeb.MetricsPlug do + @moduledoc """ + Serves PromEx metrics at `/metrics`. Optionally gated by a bearer + token via `config :microwaveprop, :prometheus_auth_token`, sourced + from the `PROMETHEUS_AUTH_TOKEN` env var in `runtime.exs`. + """ + + @behaviour Plug + + import Plug.Conn + + @impl true + def init(opts), do: opts + + @impl true + def call(conn, _opts) do + case authorized?(conn) do + :ok -> + PromEx.Plug.call(conn, prom_ex_module: Microwaveprop.PromEx, path: "/metrics") + + :denied -> + conn + |> put_resp_header("www-authenticate", ~s(Bearer realm="metrics")) + |> send_resp(401, "unauthorized") + |> halt() + end + end + + defp authorized?(conn) do + case Application.get_env(:microwaveprop, :prometheus_auth_token) do + token when is_binary(token) and token != "" -> + check_token(conn, token) + + _ -> + :ok + end + end + + defp check_token(conn, expected) do + conn + |> get_req_header("authorization") + |> List.first() + |> case do + "Bearer " <> token -> + if Plug.Crypto.secure_compare(token, expected), do: :ok, else: :denied + + _ -> + :denied + end + end +end diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 6a5e195d..f8a43cad 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -135,6 +135,14 @@ defmodule MicrowavepropWeb.Router do # Health check — no pipeline, minimal overhead get "/health", MicrowavepropWeb.HealthController, :check, log: false + # Prometheus scrape endpoint. No pipeline so there's no session / + # CSRF overhead — the intent is a dedicated external Prometheus + # server polling every 15-30s. If `PROMETHEUS_AUTH_TOKEN` is set + # (runtime.exs), the endpoint requires a matching bearer token; + # otherwise it's unauthenticated and expected to be restricted at + # the ingress / Cloudflare Access layer. + forward "/metrics", MicrowavepropWeb.MetricsPlug + # API discovery (RFC 9727 / RFC 9264). These are JSON-only, no pipeline, # no session — agents and crawlers fetch them without auth. get "/.well-known/api-catalog", MicrowavepropWeb.ApiCatalogController, :catalog diff --git a/mix.exs b/mix.exs index c749bdef..676004d6 100644 --- a/mix.exs +++ b/mix.exs @@ -66,6 +66,7 @@ defmodule Microwaveprop.MixProject do {:req, "~> 0.5"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, + {:prom_ex, "~> 1.11"}, {:gettext, "~> 0.26"}, {:jason, "~> 1.2"}, {:bandit, "~> 1.5"}, diff --git a/mix.lock b/mix.lock index 0587dad6..efd4b42f 100644 --- a/mix.lock +++ b/mix.lock @@ -3,6 +3,7 @@ "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, @@ -41,7 +42,9 @@ "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nx": {:hex, :nx, "0.11.0", "d37723dbd6cfa274a5def6d6664f5680c32e2eb8a1ce25ec6d91751967fa0abf", [:mix], [{:complex, "~> 0.6", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "36157b21239aeb251d6cbac23eb0eb3495a5e1e0cbc2e6df16afd2ede1575205"}, "oban": {:hex, :oban, "2.21.1", "4b6af7b901ef9baca09e239b5a991ef2fa429cf5a13799bc429a131d610ff692", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8162a160924cf4a25905fed2a9242e7787d88e320e3b5b0dcf324eb17c51c4e6"}, + "octo_fetch": {:hex, :octo_fetch, "0.5.0", "f50701568b9fc752656367f82cc134d5fbefff37c5a0e8ddfcceb02ceee3f5fc", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "6226cc3c14ca948ee9f25fb0446322e5c288e215da9beba7899b6b5f4cd3ccb0"}, "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, + "peep": {:hex, :peep, "3.5.0", "9f6ead7b0f2c684494200c8fc02e7e62e8c459afe861b29bd859e4c96f402ed8", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5a73a99c6e60062415efeb7e536a663387146463a3d3df1417da31fd665ac210"}, "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, @@ -54,11 +57,13 @@ "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "polaris": {:hex, :polaris, "0.1.0", "dca61b18e3e801ecdae6ac9f0eca5f19792b44a5cb4b8d63db50fc40fc038d22", [:mix], [{:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "13ef2b166650e533cb24b10e2f3b8ab4f2f449ba4d63156e8c569527f206e2c2"}, "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, + "prom_ex": {:hex, :prom_ex, "1.11.0", "1f6d67f2dead92224cb4f59beb3e4d319257c5728d9638b4a5e8ceb51a4f9c7e", [:mix], [{:absinthe, ">= 1.7.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.11.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "76b074bc3730f0802978a7eb5c7091a65473eaaf07e99ec9e933138dcc327805"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, "spitfire": {:hex, :spitfire, "0.3.10", "19aea9914132456515e8f7d592f63ab9f3130876b0252e834d2390bdd8becb24", [:mix], [], "hexpm", "6a6a5f77eb4165249c76199cd2d01fb595bac9207aed3de551918ac1c2bc9267"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, "styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"}, "sutra_ui": {:hex, :sutra_ui, "0.3.0", "c7a9790279c28aacbf0174d3a92da70af0913ada63db2eb64c499ec8f8995e83", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "d9ecb24b299090b891c50168806a894ad6359c9b3623c2bb7ebf0c7c1876cf28"}, @@ -67,6 +72,7 @@ "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},