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

@ -17,7 +17,7 @@ import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet';
import { Button } from './components/ui/button';
import { Filter, Heart } from 'lucide-react';
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types';
import { refreshListings, streamListingGeoJSON, fetchUserPOIs, fetchBulkPOIDistances, type StreamingProgress } from '@/services';
import { refreshListings, streamListingGeoJSON, fetchUserPOIs, fetchBulkPOIDistances, recordPerf, type StreamingProgress } from '@/services';
import { getCached, setCached, invalidateAll as invalidateListingCache } from '@/services/listingCache';
import { setOnUnauthorized } from '@/services/apiClient';
import { clearPasskeyUser } from './auth/passkeyService';
@ -132,7 +132,7 @@ function App() {
const loadListings = useCallback(async (parameters: ParameterValues) => {
if (!user) return;
// Check in-memory cache first
const operation = queryParameters === null ? 'initial' : 'filter_change';
const cached = getCached(parameters);
if (cached) {
setQueryParameters(parameters);
@ -158,7 +158,8 @@ function App() {
setStreamingProgress({ count: 0 });
setListingData(null);
// Dedup safety net: track seen URLs to prevent duplicate features
const loadStart = performance.now();
let firstBatchSeen = false;
const seenUrls = new Set<string>();
let updateScheduled = false;
@ -191,11 +192,16 @@ function App() {
});
if (uniqueBatch.length > 0) {
accumulatedFeaturesRef.current.push(...uniqueBatch);
if (!firstBatchSeen) {
recordPerf('time_to_first_listing', operation, (performance.now() - loadStart) / 1000);
firstBatchSeen = true;
}
scheduleUpdate();
}
}
// Final flush to ensure all data is rendered
flushUpdate();
recordPerf('page_load', operation, (performance.now() - loadStart) / 1000);
// Store successful result in frontend cache
setCached(parameters, accumulatedFeaturesRef.current);
} catch (error) {

View file

@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from 'react';
import type { AuthUser } from '@/auth/types';
import type { ListingDetailData } from '@/types';
import { fetchListingDetail } from '@/services';
import { record as recordPerf } from '@/services/perfCollector';
export function useListingDetail(user: AuthUser | null) {
const [detail, setDetail] = useState<ListingDetailData | null>(null);
@ -20,8 +21,10 @@ export function useListingDetail(user: AuthUser | null) {
}
setIsLoading(true);
setError(null);
const t0 = performance.now();
try {
const data = await fetchListingDetail(user, listingId, listingType);
recordPerf('listing_detail_load', 'fetch', (performance.now() - t0) / 1000);
cache.current.set(cacheKey, data);
setDetail(data);
} catch (e) {

View file

@ -6,6 +6,7 @@ import type { ParameterValues } from '@/components/FilterPanel';
import { ApiError } from '@/types';
import { API_ENDPOINTS } from '@/constants';
import { fireUnauthorized } from './apiClient';
import { record as recordPerf } from './perfCollector';
/**
* Build query string from parameters object
@ -99,6 +100,8 @@ export async function* streamListingGeoJSON(
const decoder = new TextDecoder();
let buffer = '';
let totalCount = 0;
let streamStart = performance.now();
let firstBatchRecorded = false;
while (true) {
if (options?.signal?.aborted) {
@ -123,6 +126,10 @@ export async function* streamListingGeoJSON(
} else if (message.type === 'batch' && message.features) {
totalCount += message.features.length;
onProgress?.({ count: totalCount });
if (!firstBatchRecorded) {
recordPerf('time_to_first_listing', 'stream', (performance.now() - streamStart) / 1000);
firstBatchRecorded = true;
}
yield message.features;
} else if (message.type === 'complete') {
onProgress?.({ count: message.total ?? totalCount, total: message.total });
@ -144,4 +151,7 @@ export async function* streamListingGeoJSON(
console.error('Failed to parse final streaming message:', e);
}
}
// Record total stream download duration
recordPerf('stream_download', 'stream', (performance.now() - streamStart) / 1000);
}