neiam
/feedpug
feedpub
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Metrics for the library: users, follows, entries, read ratio
d7c0ce0 · Gabriel Morell · 2026-08-25 02:59
Message
{commit_body(@commit)}
Files changed
modified
config/test.exs
+8
−0
@@ -33,6 +33,14 @@ config :feed_pug, FeedPug.Mailer, adapter: Swoosh.Adapters.Test
| 33 | 33 | # Disable swoosh api client as it is only required for production adapters |
| 34 | 34 | config :swoosh, :api_client, false |
| 35 | 35 | |
| 36 | +# The Library plugin's two pollers ask the database for row counts on a timer. | |
| 37 | +# Under the Ecto sandbox those queries come from an unowned process, so they | |
| 38 | +# would raise on every tick and log a warning per test run while measuring | |
| 39 | +# nothing worth measuring — the counts belong to whichever test happens to be | |
| 40 | +# mid-transaction. Every other PromEx plugin is left on: none of them query. | |
| 41 | +config :feed_pug, FeedPug.PromEx, | |
| 42 | + drop_metrics_groups: [:feedpug_library_polling_events, :feedpug_corpus_polling_events] | |
| 43 | + | |
| 36 | 44 | # Print only warnings and errors during test |
| 37 | 45 | config :logger, level: :warning |
| 38 | 46 |
modified
lib/feed_pug/prom_ex.ex
+14
−1
@@ -15,6 +15,13 @@ defmodule FeedPug.PromEx do
| 15 | 15 | cloned per app with nothing changed but the namespace. Two apps sharing a |
| 16 | 16 | namespace would collide; none do. |
| 17 | 17 | |
| 18 | + `FeedPug.PromEx.Plugins.Library` is the exception, and deliberately so: it | |
| 19 | + measures feedpug's own library rather than the stack under it, no other app | |
| 20 | + emits anything comparable, and a shared name for a metric only one app has | |
| 21 | + would be a name that means nothing anywhere else. It gets `[:phx, :feedpug]`, | |
| 22 | + the same way apollos-crib's `phx_sanctum_*` metrics are named for the one app | |
| 23 | + that has them. | |
| 24 | + | |
| 18 | 25 | ## Grafana upload is off |
| 19 | 26 | |
| 20 | 27 | PromEx can push its own bundled dashboards to Grafana on boot. That would |
@@ -46,7 +53,13 @@ defmodule FeedPug.PromEx do
| 46 | 53 | {Plugins.Ecto, repos: [FeedPug.Repo], metric_prefix: [:phx, :ecto]}, |
| 47 | 54 | # Queue depth by state is the one that matters: jobs piling up in |
| 48 | 55 | # `available` means the workers are behind. |
| 49 | − {Plugins.Oban, metric_prefix: [:phx, :oban]} | |
| 56 | + {Plugins.Oban, metric_prefix: [:phx, :oban]}, | |
| 57 | + # The only plugin here that is about feedpug rather than about the | |
| 58 | + # stack: users, followed feeds, stored entries and how much of them gets | |
| 59 | + # read, asked of the database on a timer. `phx_feedpug_` rather than a | |
| 60 | + # shared `[:phx, plugin]` name because nothing else in the fleet has | |
| 61 | + # these — see the module for why its panels aggregate with `max`. | |
| 62 | + {FeedPug.PromEx.Plugins.Library, metric_prefix: [:phx, :feedpug]} | |
| 50 | 63 | ] |
| 51 | 64 | end |
| 52 | 65 | end |
added
lib/feed_pug/prom_ex/plugins/library.ex
+382
−0
@@ -0,0 +1,382 @@
| 1 | +defmodule FeedPug.PromEx.Plugins.Library do | |
| 2 | + @moduledoc """ | |
| 3 | + What feedpug is holding: how many people use it, how many feeds they follow, | |
| 4 | + how many entries have accumulated, and how much of that gets read. | |
| 5 | + | |
| 6 | + None of this is derived from a request or a job — they are inventory | |
| 7 | + questions only the database can answer, so every metric here is polled | |
| 8 | + rather than driven by a `:telemetry` event the app already emits. That is | |
| 9 | + the same shape as `RoomObservatory.Plugins.Sanctum` in apollos-crib, and it | |
| 10 | + carries the same consequence for the dashboard: **these gauges describe the | |
| 11 | + database, not the pod.** Every replica polls and reports the same numbers, | |
| 12 | + so panels aggregate them with `max` and never `sum` — the inversion of the | |
| 13 | + rule that applies to every other panel on the feedpug dashboard. | |
| 14 | + | |
| 15 | + ## Two poll rates | |
| 16 | + | |
| 17 | + The fast group (60s) only touches tables that scale with *users*: `users`, | |
| 18 | + `feeds`, `groups`, `group_feeds`, `group_follows`. Those stay in the | |
| 19 | + thousands however long the instance runs, so a `GROUP BY` over them costs | |
| 20 | + nothing. It also carries `rows_estimate`, which reads `pg_class.reltuples` | |
| 21 | + for `items` and `item_reads` and touches neither table — the planner's | |
| 22 | + number, refreshed by autovacuum's ANALYZE, so it lags a big fetch by | |
| 23 | + minutes and is off by a percent or two. That is the right trade for a line | |
| 24 | + whose job is showing a trend. | |
| 25 | + | |
| 26 | + The slow group (15m) is the one that scans. `items` grows without bound — | |
| 27 | + every entry of every feed anyone has ever added — and the read ratio joins | |
| 28 | + `item_reads` against it. It sits in its own polling group so its slow query | |
| 29 | + runs in its own poller process and cannot delay the cheap metrics above. | |
| 30 | + | |
| 31 | + ## What "followed" means, three ways | |
| 32 | + | |
| 33 | + A feed is global and fetched once; the following happens in | |
| 34 | + `FeedPug.Groups.GroupFeed`, which places a feed in a user's group. So there | |
| 35 | + are three different true answers and all three are here: | |
| 36 | + | |
| 37 | + * `feed_placements_count` — `group_feeds` rows. One user filing the same | |
| 38 | + feed under two groups counts twice. | |
| 39 | + * `feed_subscriptions_count` — distinct `{user, feed}` pairs. This is the | |
| 40 | + "followed rss feeds" number in the ordinary sense. | |
| 41 | + * `subscribed_feeds_count` — distinct feeds with at least one placement. | |
| 42 | + Subtract it from `feeds_count` to get feeds nobody follows any more: | |
| 43 | + still fetched on their timer, still filling up `items`. | |
| 44 | + | |
| 45 | + `group_follows_count` is a fourth and unrelated kind of following — a user | |
| 46 | + following *another user's group*, which pulls that group's subtree into | |
| 47 | + their newsfeed. | |
| 48 | + | |
| 49 | + ## The read ratio, and what it leaves out | |
| 50 | + | |
| 51 | + `read_ratio` is `subscribed_item_reads / readable_items`, where a readable | |
| 52 | + item is one `{user, item}` pair the user's own subscriptions put in front of | |
| 53 | + them. Both halves are exported separately, because a ratio nobody can | |
| 54 | + decompose is a number nobody trusts. | |
| 55 | + | |
| 56 | + Two things are deliberately not in the denominator: | |
| 57 | + | |
| 58 | + * **Feeds reached through a group follow.** `FeedPug.Groups.effective_feed_ids/1` | |
| 59 | + resolves those per user in Elixir — subtree by materialized path, minus | |
| 60 | + each `FollowExclusion` subtree — which is a query per user, not an | |
| 61 | + aggregate. Doing it here would make the cost scale with the user count | |
| 62 | + on every poll to move a ratio by a few percent. | |
| 63 | + * **Reads of items that are no longer subscribed.** `item_reads` keeps the | |
| 64 | + marker after a feed leaves a group, so counting all of them against a | |
| 65 | + subscribed-only denominator would let the ratio exceed 1. The numerator | |
| 66 | + is restricted to the same `{user, feed}` set as the denominator, which | |
| 67 | + keeps it in `0..1`. `item_reads_count` is the unrestricted total, and | |
| 68 | + the gap between the two is the abandoned-subscription tail. | |
| 69 | + | |
| 70 | + ## Where the numbers go stale | |
| 71 | + | |
| 72 | + A gauge keyed by label keeps its last value until the process restarts, and | |
| 73 | + nothing here can say "this series is gone". Both label sets are closed and | |
| 74 | + zero-filled on every poll — the four `feeds.status` values from | |
| 75 | + `FeedPug.Feeds.Feed.statuses/0`, both `users.state` values — so a status | |
| 76 | + that empties out reads as zero instead of freezing at its last count. | |
| 77 | + """ | |
| 78 | + | |
| 79 | + use PromEx.Plugin | |
| 80 | + | |
| 81 | + require Logger | |
| 82 | + | |
| 83 | + alias FeedPug.Feeds.Feed | |
| 84 | + alias FeedPug.Repo | |
| 85 | + | |
| 86 | + @users_event [:feed_pug, :library, :users] | |
| 87 | + @feeds_event [:feed_pug, :library, :feeds] | |
| 88 | + @following_event [:feed_pug, :library, :following] | |
| 89 | + @estimate_event [:feed_pug, :library, :rows, :estimate] | |
| 90 | + @corpus_event [:feed_pug, :library, :corpus] | |
| 91 | + | |
| 92 | + @library_poll_rate :timer.seconds(60) | |
| 93 | + @corpus_poll_rate :timer.minutes(15) | |
| 94 | + | |
| 95 | + # The corpus query walks every row of `items` and `item_reads`. Long enough | |
| 96 | + # that doing so on a cold cache does not trip the 15s default and leave a | |
| 97 | + # permanent hole in the metric. | |
| 98 | + @corpus_timeout :timer.minutes(2) | |
| 99 | + | |
| 100 | + # Estimate-only, because these are the two tables an exact count is too | |
| 101 | + # expensive to run at the fast rate. | |
| 102 | + @estimated_tables ~w(items item_reads) | |
| 103 | + | |
| 104 | + @impl true | |
| 105 | + def polling_metrics(opts) do | |
| 106 | + prefix = Keyword.get(opts, :metric_prefix, [:phx, :feedpug]) | |
| 107 | + | |
| 108 | + [ | |
| 109 | + library_metrics(prefix, Keyword.get(opts, :poll_rate, @library_poll_rate)), | |
| 110 | + corpus_metrics(prefix, Keyword.get(opts, :corpus_poll_rate, @corpus_poll_rate)) | |
| 111 | + ] | |
| 112 | + end | |
| 113 | + | |
| 114 | + defp library_metrics(prefix, poll_rate) do | |
| 115 | + Polling.build( | |
| 116 | + :feedpug_library_polling_events, | |
| 117 | + poll_rate, | |
| 118 | + {__MODULE__, :execute_library_metrics, []}, | |
| 119 | + [ | |
| 120 | + last_value( | |
| 121 | + prefix ++ [:users, :count], | |
| 122 | + event_name: @users_event, | |
| 123 | + measurement: :users, | |
| 124 | + description: | |
| 125 | + "Registered users, split by whether they have confirmed their email address.", | |
| 126 | + tags: [:state] | |
| 127 | + ), | |
| 128 | + last_value( | |
| 129 | + prefix ++ [:feeds, :count], | |
| 130 | + event_name: @feeds_event, | |
| 131 | + measurement: :feeds, | |
| 132 | + description: | |
| 133 | + "Canonical feeds known to the instance, by fetch status. A feed is stored once no matter how many people follow it.", | |
| 134 | + tags: [:status] | |
| 135 | + ), | |
| 136 | + last_value( | |
| 137 | + prefix ++ [:feed, :subscriptions, :count], | |
| 138 | + event_name: @following_event, | |
| 139 | + measurement: :subscriptions, | |
| 140 | + description: | |
| 141 | + "Distinct {user, feed} pairs across every user's own groups — followed feeds in the ordinary sense." | |
| 142 | + ), | |
| 143 | + last_value( | |
| 144 | + prefix ++ [:feed, :placements, :count], | |
| 145 | + event_name: @following_event, | |
| 146 | + measurement: :placements, | |
| 147 | + description: | |
| 148 | + "Rows in group_feeds. Higher than the subscription count by however often a user files one feed under several groups." | |
| 149 | + ), | |
| 150 | + last_value( | |
| 151 | + prefix ++ [:subscribed, :feeds, :count], | |
| 152 | + event_name: @following_event, | |
| 153 | + measurement: :subscribed_feeds, | |
| 154 | + description: | |
| 155 | + "Distinct feeds with at least one placement. The shortfall against feeds_count is feeds nobody follows and the fetcher still polls." | |
| 156 | + ), | |
| 157 | + last_value( | |
| 158 | + prefix ++ [:group, :follows, :count], | |
| 159 | + event_name: @following_event, | |
| 160 | + measurement: :group_follows, | |
| 161 | + description: | |
| 162 | + "Users following another user's group, which pulls that group's subtree into their newsfeed." | |
| 163 | + ), | |
| 164 | + last_value( | |
| 165 | + prefix ++ [:rows, :estimate], | |
| 166 | + event_name: @estimate_event, | |
| 167 | + measurement: :rows, | |
| 168 | + description: | |
| 169 | + "Planner row estimate (pg_class.reltuples) for the two unbounded tables. Refreshed by ANALYZE, so it lags a large fetch.", | |
| 170 | + tags: [:table] | |
| 171 | + ) | |
| 172 | + ], | |
| 173 | + detach_on_error: false | |
| 174 | + ) | |
| 175 | + end | |
| 176 | + | |
| 177 | + defp corpus_metrics(prefix, poll_rate) do | |
| 178 | + Polling.build( | |
| 179 | + :feedpug_corpus_polling_events, | |
| 180 | + poll_rate, | |
| 181 | + {__MODULE__, :execute_corpus_metrics, []}, | |
| 182 | + [ | |
| 183 | + last_value( | |
| 184 | + prefix ++ [:items, :count], | |
| 185 | + event_name: @corpus_event, | |
| 186 | + measurement: :items, | |
| 187 | + description: "Exact count of stored feed entries across every feed." | |
| 188 | + ), | |
| 189 | + last_value( | |
| 190 | + prefix ++ [:item, :reads, :count], | |
| 191 | + event_name: @corpus_event, | |
| 192 | + measurement: :item_reads, | |
| 193 | + description: | |
| 194 | + "Exact count of read markers, including markers left on items the user no longer subscribes to." | |
| 195 | + ), | |
| 196 | + last_value( | |
| 197 | + prefix ++ [:readable, :items, :count], | |
| 198 | + event_name: @corpus_event, | |
| 199 | + measurement: :readable_items, | |
| 200 | + description: | |
| 201 | + "{user, item} pairs a user's own subscriptions put in front of them — the read ratio's denominator. Excludes feeds reached through a group follow." | |
| 202 | + ), | |
| 203 | + last_value( | |
| 204 | + prefix ++ [:subscribed, :item, :reads, :count], | |
| 205 | + event_name: @corpus_event, | |
| 206 | + measurement: :subscribed_item_reads, | |
| 207 | + description: | |
| 208 | + "Read markers on items the user still subscribes to — the read ratio's numerator, on the same {user, feed} set as the denominator." | |
| 209 | + ), | |
| 210 | + last_value( | |
| 211 | + prefix ++ [:read, :ratio], | |
| 212 | + event_name: @corpus_event, | |
| 213 | + measurement: :read_ratio, | |
| 214 | + description: | |
| 215 | + "subscribed_item_reads / readable_items, in 0..1. 0 when nothing is subscribed at all." | |
| 216 | + ) | |
| 217 | + ], | |
| 218 | + detach_on_error: false | |
| 219 | + ) | |
| 220 | + end | |
| 221 | + | |
| 222 | + @doc false | |
| 223 | + def execute_library_metrics do | |
| 224 | + emit_user_counts() | |
| 225 | + emit_feed_counts() | |
| 226 | + emit_following_counts() | |
| 227 | + emit_row_estimates() | |
| 228 | + rescue | |
| 229 | + error -> log_skipped(:library, error) | |
| 230 | + catch | |
| 231 | + :exit, reason -> log_skipped(:library, reason) | |
| 232 | + end | |
| 233 | + | |
| 234 | + @doc false | |
| 235 | + def execute_corpus_metrics do | |
| 236 | + emit_corpus_counts() | |
| 237 | + rescue | |
| 238 | + error -> log_skipped(:corpus, error) | |
| 239 | + catch | |
| 240 | + :exit, reason -> log_skipped(:corpus, reason) | |
| 241 | + end | |
| 242 | + | |
| 243 | + # -- users --------------------------------------------------------------- | |
| 244 | + | |
| 245 | + defp emit_user_counts do | |
| 246 | + %{rows: rows} = | |
| 247 | + Repo.query!("SELECT confirmed_at IS NOT NULL, count(*) FROM users GROUP BY 1") | |
| 248 | + | |
| 249 | + counts = Map.new(rows, fn [confirmed, count] -> {confirmed, count} end) | |
| 250 | + | |
| 251 | + # Both states every poll: an instance where everybody has confirmed should | |
| 252 | + # read `pending 0`, not go quiet. | |
| 253 | + for {confirmed, state} <- [{true, "confirmed"}, {false, "pending"}] do | |
| 254 | + :telemetry.execute( | |
| 255 | + @users_event, | |
| 256 | + %{users: Map.get(counts, confirmed, 0)}, | |
| 257 | + %{state: state} | |
| 258 | + ) | |
| 259 | + end | |
| 260 | + end | |
| 261 | + | |
| 262 | + # -- feeds --------------------------------------------------------------- | |
| 263 | + | |
| 264 | + defp emit_feed_counts do | |
| 265 | + %{rows: rows} = Repo.query!("SELECT status, count(*) FROM feeds GROUP BY 1") | |
| 266 | + | |
| 267 | + counts = Map.new(rows, fn [status, count] -> {status_label(status), count} end) | |
| 268 | + | |
| 269 | + # Zero-filled across the schema's full status list rather than only the | |
| 270 | + # statuses present, so `failed` dropping back to zero is visible as a line | |
| 271 | + # returning to zero rather than as a series that stops. | |
| 272 | + statuses = Enum.uniq(Feed.statuses() ++ Map.keys(counts)) | |
| 273 | + | |
| 274 | + for status <- statuses do | |
| 275 | + :telemetry.execute(@feeds_event, %{feeds: Map.get(counts, status, 0)}, %{status: status}) | |
| 276 | + end | |
| 277 | + end | |
| 278 | + | |
| 279 | + defp status_label(nil), do: "unknown" | |
| 280 | + defp status_label(status), do: to_string(status) | |
| 281 | + | |
| 282 | + # -- following ----------------------------------------------------------- | |
| 283 | + | |
| 284 | + @following_query """ | |
| 285 | + SELECT | |
| 286 | + (SELECT count(*) FROM group_feeds), | |
| 287 | + (SELECT count(*) FROM ( | |
| 288 | + SELECT DISTINCT g.user_id, gf.feed_id | |
| 289 | + FROM group_feeds gf | |
| 290 | + JOIN groups g ON g.id = gf.group_id | |
| 291 | + ) s), | |
| 292 | + (SELECT count(DISTINCT feed_id) FROM group_feeds), | |
| 293 | + (SELECT count(*) FROM group_follows) | |
| 294 | + """ | |
| 295 | + | |
| 296 | + defp emit_following_counts do | |
| 297 | + %{rows: [[placements, subscriptions, subscribed_feeds, group_follows]]} = | |
| 298 | + Repo.query!(@following_query) | |
| 299 | + | |
| 300 | + :telemetry.execute(@following_event, %{ | |
| 301 | + placements: placements, | |
| 302 | + subscriptions: subscriptions, | |
| 303 | + subscribed_feeds: subscribed_feeds, | |
| 304 | + group_follows: group_follows | |
| 305 | + }) | |
| 306 | + end | |
| 307 | + | |
| 308 | + # -- estimates ----------------------------------------------------------- | |
| 309 | + | |
| 310 | + @estimate_query """ | |
| 311 | + SELECT c.relname::text, GREATEST(c.reltuples, 0)::bigint | |
| 312 | + FROM pg_class c | |
| 313 | + JOIN pg_namespace n ON n.oid = c.relnamespace | |
| 314 | + WHERE n.nspname = current_schema() | |
| 315 | + AND c.relkind = 'r' | |
| 316 | + AND c.relname::text = ANY($1) | |
| 317 | + """ | |
| 318 | + | |
| 319 | + defp emit_row_estimates do | |
| 320 | + %{rows: rows} = Repo.query!(@estimate_query, [@estimated_tables]) | |
| 321 | + estimates = Map.new(rows, fn [table, estimate] -> {table, estimate} end) | |
| 322 | + | |
| 323 | + # A table missing from pg_class has not been migrated yet; report 0 rather | |
| 324 | + # than leaving a gap, so the series exists from the first scrape. | |
| 325 | + for table <- @estimated_tables do | |
| 326 | + :telemetry.execute(@estimate_event, %{rows: Map.get(estimates, table, 0)}, %{table: table}) | |
| 327 | + end | |
| 328 | + end | |
| 329 | + | |
| 330 | + # -- corpus and read ratio ----------------------------------------------- | |
| 331 | + | |
| 332 | + # `subs` is the {user, feed} set a user's own groups subscribe them to. | |
| 333 | + # `per_feed` collapses items to one row per feed *before* the join, so the | |
| 334 | + # denominator costs one aggregate over items rather than a DISTINCT over | |
| 335 | + # every {user, item} pair it implies. | |
| 336 | + @corpus_query """ | |
| 337 | + WITH subs AS ( | |
| 338 | + SELECT DISTINCT g.user_id, gf.feed_id | |
| 339 | + FROM group_feeds gf | |
| 340 | + JOIN groups g ON g.id = gf.group_id | |
| 341 | + ), | |
| 342 | + per_feed AS ( | |
| 343 | + SELECT feed_id, count(*) AS items FROM items GROUP BY 1 | |
| 344 | + ) | |
| 345 | + SELECT | |
| 346 | + (SELECT count(*) FROM items), | |
| 347 | + (SELECT count(*) FROM item_reads), | |
| 348 | + -- ::bigint because sum() over a count is numeric, which Postgrex hands | |
| 349 | + -- back as a Decimal — and a Decimal denominator makes `ratio/2` raise | |
| 350 | + -- rather than divide. | |
| 351 | + (SELECT COALESCE(sum(pf.items), 0)::bigint | |
| 352 | + FROM subs s JOIN per_feed pf ON pf.feed_id = s.feed_id), | |
| 353 | + (SELECT count(*) | |
| 354 | + FROM item_reads r | |
| 355 | + JOIN items i ON i.id = r.item_id | |
| 356 | + JOIN subs s ON s.user_id = r.user_id AND s.feed_id = i.feed_id) | |
| 357 | + """ | |
| 358 | + | |
| 359 | + defp emit_corpus_counts do | |
| 360 | + %{rows: [[items, item_reads, readable_items, subscribed_item_reads]]} = | |
| 361 | + Repo.query!(@corpus_query, [], timeout: @corpus_timeout) | |
| 362 | + | |
| 363 | + :telemetry.execute(@corpus_event, %{ | |
| 364 | + items: items, | |
| 365 | + item_reads: item_reads, | |
| 366 | + readable_items: readable_items, | |
| 367 | + subscribed_item_reads: subscribed_item_reads, | |
| 368 | + read_ratio: ratio(subscribed_item_reads, readable_items) | |
| 369 | + }) | |
| 370 | + end | |
| 371 | + | |
| 372 | + # A fresh instance with no subscriptions has read nothing rather than an | |
| 373 | + # undefined amount, and 0/0 is the state it spends its first day in. | |
| 374 | + defp ratio(_reads, 0), do: 0.0 | |
| 375 | + defp ratio(reads, readable), do: reads / readable | |
| 376 | + | |
| 377 | + defp log_skipped(group, reason) do | |
| 378 | + Logger.warning("FeedPug.PromEx.Plugins.Library skipped #{group} metrics: #{inspect(reason)}") | |
| 379 | + | |
| 380 | + :ok | |
| 381 | + end | |
| 382 | +end |
added
test/feed_pug/prom_ex/plugins/library_test.exs
+225
−0
@@ -0,0 +1,225 @@
| 1 | +defmodule FeedPug.PromEx.Plugins.LibraryTest do | |
| 2 | + @moduledoc """ | |
| 3 | + The plugin's two pollers run raw SQL against tables no context module owns, | |
| 4 | + so nothing else in the suite would notice these queries drifting from the | |
| 5 | + schema. Each test drives the poll function directly and reads the telemetry | |
| 6 | + it emits. | |
| 7 | + """ | |
| 8 | + use FeedPug.DataCase, async: true | |
| 9 | + | |
| 10 | + import FeedPug.AccountsFixtures | |
| 11 | + | |
| 12 | + alias FeedPug.Feeds | |
| 13 | + alias FeedPug.Groups | |
| 14 | + alias FeedPug.PromEx.Plugins.Library | |
| 15 | + | |
| 16 | + @events [ | |
| 17 | + [:feed_pug, :library, :users], | |
| 18 | + [:feed_pug, :library, :feeds], | |
| 19 | + [:feed_pug, :library, :following], | |
| 20 | + [:feed_pug, :library, :rows, :estimate], | |
| 21 | + [:feed_pug, :library, :corpus] | |
| 22 | + ] | |
| 23 | + | |
| 24 | + setup do | |
| 25 | + ref = make_ref() | |
| 26 | + test = self() | |
| 27 | + | |
| 28 | + :telemetry.attach_many( | |
| 29 | + "library-test-#{inspect(ref)}", | |
| 30 | + @events, | |
| 31 | + fn event, measurements, metadata, _ -> | |
| 32 | + send(test, {:metric, event, measurements, metadata}) | |
| 33 | + end, | |
| 34 | + nil | |
| 35 | + ) | |
| 36 | + | |
| 37 | + on_exit(fn -> :telemetry.detach("library-test-#{inspect(ref)}") end) | |
| 38 | + :ok | |
| 39 | + end | |
| 40 | + | |
| 41 | + # Every emission for one event, as {metadata, measurements} pairs. Drains the | |
| 42 | + # mailbox rather than matching one message, because the zero-filled metrics | |
| 43 | + # emit once per label value. | |
| 44 | + defp collect(event) do | |
| 45 | + receive do | |
| 46 | + {:metric, ^event, measurements, metadata} -> [{metadata, measurements} | collect(event)] | |
| 47 | + after | |
| 48 | + 0 -> [] | |
| 49 | + end | |
| 50 | + end | |
| 51 | + | |
| 52 | + defp single(event) do | |
| 53 | + Library.execute_library_metrics() | |
| 54 | + [{_metadata, measurements}] = collect(event) | |
| 55 | + measurements | |
| 56 | + end | |
| 57 | + | |
| 58 | + defp group(scope, path) do | |
| 59 | + Enum.find(Groups.list_groups(scope), &(&1.path == path)) | |
| 60 | + end | |
| 61 | + | |
| 62 | + defp add_feed(scope, path, url) do | |
| 63 | + {:ok, group_feed} = Groups.add_feed_to_group(scope, group(scope, path), url) | |
| 64 | + Feeds.get_feed!(group_feed.feed_id) | |
| 65 | + end | |
| 66 | + | |
| 67 | + defp add_items(feed, count) do | |
| 68 | + entries = for n <- 1..count, do: %{guid: "#{feed.id}-#{n}", title: "item #{n}"} | |
| 69 | + {_n, items} = Feeds.store_items(feed, entries) | |
| 70 | + items | |
| 71 | + end | |
| 72 | + | |
| 73 | + describe "users" do | |
| 74 | + test "splits on confirmation and reports both states even when one is empty" do | |
| 75 | + user_fixture() | |
| 76 | + user_fixture() | |
| 77 | + | |
| 78 | + Library.execute_library_metrics() | |
| 79 | + | |
| 80 | + counts = | |
| 81 | + [:feed_pug, :library, :users] | |
| 82 | + |> collect() | |
| 83 | + |> Map.new(fn {%{state: state}, %{users: users}} -> {state, users} end) | |
| 84 | + | |
| 85 | + assert counts == %{"confirmed" => 2, "pending" => 0} | |
| 86 | + end | |
| 87 | + | |
| 88 | + test "counts an unconfirmed registration as pending" do | |
| 89 | + unconfirmed_user_fixture() | |
| 90 | + | |
| 91 | + Library.execute_library_metrics() | |
| 92 | + | |
| 93 | + counts = | |
| 94 | + [:feed_pug, :library, :users] | |
| 95 | + |> collect() | |
| 96 | + |> Map.new(fn {%{state: state}, %{users: users}} -> {state, users} end) | |
| 97 | + | |
| 98 | + assert counts == %{"confirmed" => 0, "pending" => 1} | |
| 99 | + end | |
| 100 | + end | |
| 101 | + | |
| 102 | + describe "feeds" do | |
| 103 | + test "reports every status in the schema, not only the ones in use" do | |
| 104 | + scope = user_scope_fixture() | |
| 105 | + feed = add_feed(scope, "blogs", "https://a.example/feed.xml") | |
| 106 | + {:ok, _} = Feeds.update_feed(feed, %{status: "failed"}) | |
| 107 | + | |
| 108 | + Library.execute_library_metrics() | |
| 109 | + | |
| 110 | + counts = | |
| 111 | + [:feed_pug, :library, :feeds] | |
| 112 | + |> collect() | |
| 113 | + |> Map.new(fn {%{status: status}, %{feeds: feeds}} -> {status, feeds} end) | |
| 114 | + | |
| 115 | + assert counts == %{"active" => 0, "paused" => 0, "failed" => 1, "archived" => 0} | |
| 116 | + end | |
| 117 | + end | |
| 118 | + | |
| 119 | + describe "following" do | |
| 120 | + test "separates placements, distinct subscriptions and distinct feeds" do | |
| 121 | + scope = user_scope_fixture() | |
| 122 | + other = user_scope_fixture() | |
| 123 | + | |
| 124 | + # One feed filed under two of the same user's groups: two placements, | |
| 125 | + # one subscription, one feed. A second user following it adds a | |
| 126 | + # subscription without adding a feed. | |
| 127 | + shared = "https://shared.example/feed.xml" | |
| 128 | + add_feed(scope, "blogs", shared) | |
| 129 | + add_feed(scope, "news", shared) | |
| 130 | + add_feed(other, "blogs", shared) | |
| 131 | + add_feed(scope, "comics", "https://solo.example/feed.xml") | |
| 132 | + | |
| 133 | + assert %{ | |
| 134 | + placements: 4, | |
| 135 | + subscriptions: 3, | |
| 136 | + subscribed_feeds: 2, | |
| 137 | + group_follows: 0 | |
| 138 | + } = single([:feed_pug, :library, :following]) | |
| 139 | + end | |
| 140 | + | |
| 141 | + test "counts a follow of another user's group" do | |
| 142 | + scope = user_scope_fixture() | |
| 143 | + other = user_scope_fixture() | |
| 144 | + {:ok, _follow} = Groups.follow_group(other, group(scope, "blogs")) | |
| 145 | + | |
| 146 | + assert %{group_follows: 1} = single([:feed_pug, :library, :following]) | |
| 147 | + end | |
| 148 | + end | |
| 149 | + | |
| 150 | + describe "corpus and read ratio" do | |
| 151 | + test "counts items and reads, and divides only over what is subscribed" do | |
| 152 | + scope = user_scope_fixture() | |
| 153 | + followed = add_feed(scope, "blogs", "https://followed.example/feed.xml") | |
| 154 | + | |
| 155 | + # A feed nobody has in a group: its items are stored and fetched, and | |
| 156 | + # they are not readable by anyone. | |
| 157 | + {:ok, orphan} = Feeds.upsert_feed_by_url("https://orphan.example/feed.xml") | |
| 158 | + | |
| 159 | + [a, b, _c, _d] = add_items(followed, 4) | |
| 160 | + [e | _] = add_items(orphan, 2) | |
| 161 | + | |
| 162 | + :ok = Feeds.mark_read(scope.user.id, a.id) | |
| 163 | + :ok = Feeds.mark_read(scope.user.id, b.id) | |
| 164 | + # A read left behind on a feed the user does not subscribe to. It counts | |
| 165 | + # as a read marker and must not count towards the ratio, which would | |
| 166 | + # otherwise be able to exceed 1. | |
| 167 | + :ok = Feeds.mark_read(scope.user.id, e.id) | |
| 168 | + | |
| 169 | + Library.execute_corpus_metrics() | |
| 170 | + | |
| 171 | + assert [{_meta, measurements}] = collect([:feed_pug, :library, :corpus]) | |
| 172 | + | |
| 173 | + assert %{ | |
| 174 | + items: 6, | |
| 175 | + item_reads: 3, | |
| 176 | + readable_items: 4, | |
| 177 | + subscribed_item_reads: 2, | |
| 178 | + read_ratio: ratio | |
| 179 | + } = measurements | |
| 180 | + | |
| 181 | + # A float, not a Decimal: `sum()` over a count comes back as numeric, and | |
| 182 | + # dividing by the Decimal that produces raises instead. | |
| 183 | + assert is_float(ratio) | |
| 184 | + assert ratio == 0.5 | |
| 185 | + end | |
| 186 | + | |
| 187 | + test "counts the same item once per subscriber" do | |
| 188 | + scope = user_scope_fixture() | |
| 189 | + other = user_scope_fixture() | |
| 190 | + shared = "https://shared.example/feed.xml" | |
| 191 | + feed = add_feed(scope, "blogs", shared) | |
| 192 | + add_feed(other, "blogs", shared) | |
| 193 | + [a, _b] = add_items(feed, 2) | |
| 194 | + | |
| 195 | + :ok = Feeds.mark_read(scope.user.id, a.id) | |
| 196 | + | |
| 197 | + Library.execute_corpus_metrics() | |
| 198 | + [{_meta, measurements}] = collect([:feed_pug, :library, :corpus]) | |
| 199 | + | |
| 200 | + assert %{items: 2, readable_items: 4, subscribed_item_reads: 1, read_ratio: 0.25} = | |
| 201 | + measurements | |
| 202 | + end | |
| 203 | + | |
| 204 | + test "reports a ratio of zero rather than dividing by nothing" do | |
| 205 | + Library.execute_corpus_metrics() | |
| 206 | + [{_meta, measurements}] = collect([:feed_pug, :library, :corpus]) | |
| 207 | + | |
| 208 | + assert %{items: 0, item_reads: 0, readable_items: 0, read_ratio: +0.0} = measurements | |
| 209 | + end | |
| 210 | + end | |
| 211 | + | |
| 212 | + describe "row estimates" do | |
| 213 | + test "reports both unbounded tables on every poll" do | |
| 214 | + Library.execute_library_metrics() | |
| 215 | + | |
| 216 | + tables = | |
| 217 | + [:feed_pug, :library, :rows, :estimate] | |
| 218 | + |> collect() | |
| 219 | + |> Enum.map(fn {%{table: table}, _} -> table end) | |
| 220 | + |> Enum.sort() | |
| 221 | + | |
| 222 | + assert tables == ["item_reads", "items"] | |
| 223 | + end | |
| 224 | + end | |
| 225 | +end |
Parents: f6646e9