Metrics for the library: users, follows, entries, read ratio

d7c0ce0 · Gabriel Morell · 2026-08-25 02:59

13 files +629 -1
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