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:
parent
1ae00b7cbf
commit
35f1987ac1
11 changed files with 1236 additions and 26 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue