Add navigation & usage metrics for end-user experience visibility

Instrument DB query timing (11 operations across 3 repositories),
streaming lifecycle (TTFB, duration, feature count), cache operation
latency, listing detail step breakdown, and frontend page load /
time-to-first-listing / stream download / detail load metrics.

Adds 16 new OTel instruments, extends the perf ingestion endpoint
with 4 new frontend metrics, and adds ~20 Grafana dashboard panels
across 4 new rows (DB Query Performance, Streaming Performance,
Listing Detail Breakdown, Cache Performance, Frontend Navigation).
This commit is contained in:
Viktor Barzin 2026-02-23 20:28:42 +00:00
parent 1ae00b7cbf
commit 35f1987ac1
No known key found for this signature in database
GPG key ID: 0EB088298288D958
11 changed files with 1236 additions and 26 deletions

View file

@ -1,5 +1,7 @@
from datetime import datetime
import time
from api.metrics import record_db_query
from models.decision import ListingDecision
from sqlalchemy import Engine
from sqlmodel import Session, select
@ -19,6 +21,7 @@ class DecisionRepository:
decision: str,
) -> ListingDecision:
"""Create or update a decision. Uses dialect-specific upsert."""
t0 = time.monotonic()
with Session(self.engine) as session:
now = datetime.utcnow()
values = {
@ -58,14 +61,18 @@ class DecisionRepository:
)
).first()
assert result is not None
return result
record_db_query("upsert_decision", "decision", time.monotonic() - t0)
return result
def get_decisions_for_user(self, user_id: int) -> list[ListingDecision]:
t0 = time.monotonic()
with Session(self.engine) as session:
statement = select(ListingDecision).where(
ListingDecision.user_id == user_id
)
return list(session.exec(statement).all())
results = list(session.exec(statement).all())
record_db_query("get_decisions_for_user", "decision", time.monotonic() - t0, len(results))
return results
def delete_decision(
self,
@ -92,13 +99,16 @@ class DecisionRepository:
user_id: int,
listing_type: str,
) -> set[int]:
t0 = time.monotonic()
with Session(self.engine) as session:
statement = select(ListingDecision.listing_id).where(
ListingDecision.user_id == user_id,
ListingDecision.listing_type == listing_type,
ListingDecision.decision == "disliked",
)
return {row for row in session.exec(statement).all()}
ids = {row for row in session.exec(statement).all()}
record_db_query("get_disliked_listing_ids", "decision", time.monotonic() - t0, len(ids))
return ids
def get_liked_listing_ids(
self,

View file

@ -1,6 +1,8 @@
from datetime import datetime, timedelta
import logging
import time
from typing import Any, Generator
from api.metrics import record_db_query
from data_access import Listing
from models.listing import (
BuyListing,
@ -55,8 +57,10 @@ class ListingRepository:
if limit:
query = query.limit(limit)
t0 = time.monotonic()
with Session(self.engine) as session:
rows = list(session.exec(query).all())
record_db_query("get_listings", model.__tablename__, time.monotonic() - t0, len(rows))
logging.debug(f"Found {len(rows)} listings")
return rows
@ -110,8 +114,11 @@ class ListingRepository:
query = sa_select(func.count(model.id))
query = self._apply_query_filters(query, model, query_parameters)
t0 = time.monotonic()
with Session(self.engine) as session:
return session.execute(query).scalar() or 0
result = session.execute(query).scalar() or 0
record_db_query("count_listings", model.__tablename__, time.monotonic() - t0, result)
return result
def stream_listings_optimized(
self,
@ -157,8 +164,10 @@ class ListingRepository:
batch_limit = min(page_size, limit - total_yielded)
query = query.order_by(model.id).limit(batch_limit)
t0 = time.monotonic()
with Session(self.engine) as session:
results = session.execute(query).fetchall()
record_db_query("stream_listings_page", model.__tablename__, time.monotonic() - t0, len(results))
if not results:
break
@ -364,6 +373,7 @@ class ListingRepository:
Checks both RentListing and BuyListing tables and returns the latest.
"""
t0 = time.monotonic()
with Session(self.engine) as session:
rent_max = session.execute(
sa_select(func.max(RentListing.last_seen))
@ -371,6 +381,7 @@ class ListingRepository:
buy_max = session.execute(
sa_select(func.max(BuyListing.last_seen))
).scalar()
record_db_query("get_last_updated", "rent", time.monotonic() - t0)
candidates = [t for t in (rent_max, buy_max) if t is not None]
return max(candidates) if candidates else None
@ -385,9 +396,12 @@ class ListingRepository:
filtering against API results.
"""
model = RentListing if listing_type == ListingType.RENT else BuyListing
t0 = time.monotonic()
with Session(self.engine) as session:
result = session.execute(sa_select(model.id))
return {row[0] for row in result.fetchall()}
ids = {row[0] for row in result.fetchall()}
record_db_query("get_listing_ids", model.__tablename__, time.monotonic() - t0, len(ids))
return ids
async def mark_seen(self, listing_id: int, listing_type: ListingType = ListingType.RENT) -> None:
query_params = QueryParameters(listing_type=listing_type)

View file

@ -1,3 +1,5 @@
import time
from api.metrics import record_db_query
from models.listing import ListingType
from models.poi import PointOfInterest
from models.poi_distance import POIDistance
@ -12,11 +14,14 @@ class POIRepository:
self.engine = engine
def get_pois_for_user(self, user_id: int) -> list[PointOfInterest]:
t0 = time.monotonic()
with Session(self.engine) as session:
statement = select(PointOfInterest).where(
PointOfInterest.user_id == user_id
)
return list(session.exec(statement).all())
results = list(session.exec(statement).all())
record_db_query("get_pois_for_user", "poi", time.monotonic() - t0, len(results))
return results
def get_poi_by_id(self, poi_id: int) -> PointOfInterest | None:
with Session(self.engine) as session:
@ -55,6 +60,7 @@ class POIRepository:
"""Insert or update POI distances, handling duplicate unique constraints."""
if not distances:
return
t0 = time.monotonic()
with Session(self.engine) as session:
dialect = self.engine.dialect.name
for dist in distances:
@ -88,6 +94,7 @@ class POIRepository:
)
session.execute(stmt)
session.commit()
record_db_query("upsert_distances", "poi_distance", time.monotonic() - t0, len(distances))
def get_distances_for_listings(
self,
@ -95,6 +102,7 @@ class POIRepository:
listing_type: ListingType,
user_id: int,
) -> list[POIDistance]:
t0 = time.monotonic()
with Session(self.engine) as session:
# Join with POI to filter by user
statement = (
@ -106,7 +114,9 @@ class POIRepository:
PointOfInterest.user_id == user_id,
)
)
return list(session.exec(statement).all())
results = list(session.exec(statement).all())
record_db_query("get_distances_for_listings", "poi_distance", time.monotonic() - t0, len(results))
return results
def get_distances_for_poi(self, poi_id: int) -> list[POIDistance]:
with Session(self.engine) as session: