Trace each step of the feed list (#164)

GET /api/feeds took 250-300 ms in production with nothing under it in its trace but feed_summary
spans of a millisecond, so there was no telling where the time went. feed_rows now wraps the
catalogue, the person's subscriptions, the subscriber counts, the pins and feed_list in a step
span each, and feed_list each of its five queries.

db::step makes a span only inside one already: the healthcheck's status every 30 s and the page's
live updates run the same code outside any trace, where each span would be a trace of its own, as
feed_summary's already are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 02:04:45 +00:00
parent 2de5f81202
commit 6a69818ef0
3 changed files with 28 additions and 13 deletions

View File

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- The feed list's traces show each of its database queries, so it can be told where its time goes.
- Saving a feed's settings asks the database for your subscription once, not twice. - Saving a feed's settings asks the database for your subscription once, not twice.
- `ipx add` and Add feed in the page add a feed the same way, through one piece of code. - `ipx add` and Add feed in the page add a feed the same way, through one piece of code.
- Glass's top bar is solid on a phone, in the colour its glass showed, so the strip above it on an iPhone can match it. - Glass's top bar is solid on a phone, in the colour its glass showed, so the strip above it on an iPhone can match it.

View File

@@ -390,26 +390,30 @@ impl Db {
) -> Result<std::collections::HashMap<String, FeedListing>> { ) -> Result<std::collections::HashMap<String, FeedListing>> {
use std::collections::HashMap; use std::collections::HashMap;
let one = || sea_orm::Value::from(only.map(str::to_owned)); let one = || sea_orm::Value::from(only.map(str::to_owned));
let counts = |sql: &'static str, args: Vec<sea_orm::Value>| async move { // A step a query: the feed list's trace was 290 ms with nothing under it.
self.rows(sql, args) let counts = |of: &'static str, sql: &'static str, args: Vec<sea_orm::Value>| async move {
tracing::Instrument::instrument(self.rows(sql, args), step(of))
.await? .await?
.iter() .iter()
.map(|r| Ok((r.try_get::<String>("", "feed_id")?, r.try_get::<i64>("", "n")?))) .map(|r| Ok((r.try_get::<String>("", "feed_id")?, r.try_get::<i64>("", "n")?)))
.collect::<Result<HashMap<_, _>>>() .collect::<Result<HashMap<_, _>>>()
}; };
let entries = counts( let entries = counts(
"entries",
"SELECT feed_id, count(*) AS n FROM entries "SELECT feed_id, count(*) AS n FROM entries
WHERE (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id", WHERE (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id",
vec![one()], vec![one()],
) )
.await?; .await?;
let downloaded = counts( let downloaded = counts(
"downloaded",
"SELECT feed_id, count(*) AS n FROM enclosures "SELECT feed_id, count(*) AS n FROM enclosures
WHERE path IS NOT NULL AND (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id", WHERE path IS NOT NULL AND (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id",
vec![one()], vec![one()],
) )
.await?; .await?;
let unread = counts( let unread = counts(
"unread",
"SELECT e.feed_id, count(*) AS n FROM entries e "SELECT e.feed_id, count(*) AS n FROM entries e
JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id
LEFT JOIN entry_state s LEFT JOIN entry_state s
@@ -422,10 +426,11 @@ impl Db {
vec![user_id.into(), one()], vec![user_id.into(), one()],
) )
.await?; .await?;
let mut blocked: HashMap<String, Vec<String>> = blocklists::Entity::find() let mut blocked: HashMap<String, Vec<String>> = tracing::Instrument::instrument(
.filter(blocklists::Column::UserId.eq(user_id)) blocklists::Entity::find().filter(blocklists::Column::UserId.eq(user_id)).all(&self.orm),
.all(&self.orm) step("blocked"),
.await? )
.await?
.into_iter() .into_iter()
.map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default())) .map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default()))
.collect(); .collect();
@@ -433,8 +438,7 @@ impl Db {
if let Some(id) = only { if let Some(id) = only {
rows = rows.filter(feeds::Column::Id.eq(id)); rows = rows.filter(feeds::Column::Id.eq(id));
} }
Ok(rows Ok(tracing::Instrument::instrument(rows.all(&self.orm), step("feeds"))
.all(&self.orm)
.await? .await?
.into_iter() .into_iter()
.map(|f| { .map(|f| {
@@ -2173,6 +2177,12 @@ pub enum EntryFlag {
Flagged, Flagged,
} }
/// A span for one step of a request or a scan, and none outside one: the healthcheck and the page's
/// live updates run the same code, and every call there would be a trace of its own.
pub fn step(of: &'static str) -> tracing::Span {
if tracing::Span::current().is_none() { tracing::Span::none() } else { tracing::info_span!("step", of) }
}
/// Unix seconds. Everything time-shaped in the DB is stored this way. /// Unix seconds. Everything time-shaped in the DB is stored this way.
pub fn now() -> i64 { pub fn now() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()

View File

@@ -807,20 +807,24 @@ async fn feeds(
/// The feed list as this person sees it, or with `only` the one row for that feed: what a live /// The feed list as this person sees it, or with `only` the one row for that feed: what a live
/// update sends after the feed changes, so the page redraws a row instead of reloading the list. /// update sends after the feed changes, so the page redraws a row instead of reloading the list.
async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>) -> anyhow::Result<Vec<FeedRow>> { async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>) -> anyhow::Result<Vec<FeedRow>> {
use crate::db::step;
use tracing::Instrument;
let cfg = state.ctx.cfg(); let cfg = state.ctx.cfg();
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue. // Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
// What comes back is only the part of it this person subscribes to. // What comes back is only the part of it this person subscribes to.
let subs = crate::subscriptions(&state.ctx).await?; let subs = crate::subscriptions(&state.ctx).instrument(step("catalogue")).await?;
let mine: std::collections::HashMap<String, crate::db::Sub> = state let mine: std::collections::HashMap<String, crate::db::Sub> = state
.ctx .ctx
.db .db
.subscriptions_for(user.id).await? .subscriptions_for(user.id)
.instrument(step("subscriptions"))
.await?
.into_iter() .into_iter()
.map(|s| (s.feed_id.clone(), s)) .map(|s| (s.feed_id.clone(), s))
.collect(); .collect();
let counts = state.ctx.db.subscriber_counts().await?; let counts = state.ctx.db.subscriber_counts().instrument(step("subscriber_counts")).await?;
let pinned = state.ctx.db.pinned_feeds(user.id).await?; let pinned = state.ctx.db.pinned_feeds(user.id).instrument(step("pinned")).await?;
let mut listed = state.ctx.db.feed_list(user.id, only).await?; let mut listed = state.ctx.db.feed_list(user.id, only).instrument(step("feed_list")).await?;
let mut out = Vec::with_capacity(mine.len()); let mut out = Vec::with_capacity(mine.len());
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) { for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
let (id, feed) = (&sub.id, &sub.cfg); let (id, feed) = (&sub.id, &sub.cfg);