From d90fa38776fe14c8fd0371c1990d5f445bc9cb8e Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 22 Feb 2026 17:30:29 +0000 Subject: [PATCH] Add frontend performance metrics pipeline to Prometheus Collect browser-side worker round-trips, computation times, main-thread operations, and feature counts, batch them client-side, and expose as Prometheus histograms via a new POST /api/perf endpoint. --- api/app.py | 2 + api/metrics.py | 28 ++++++++++ api/perf_routes.py | 43 +++++++++++++++ frontend/src/main.tsx | 3 ++ frontend/src/services/index.ts | 1 + frontend/src/services/perfCollector.ts | 46 ++++++++++++++++ frontend/src/workers/HexgridHeatmapClient.ts | 55 ++++++++++++++++++-- frontend/src/workers/hexgrid.worker.ts | 9 ++++ frontend/src/workers/types.ts | 4 ++ frontend/tsconfig.app.tsbuildinfo | 2 +- 10 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 api/perf_routes.py create mode 100644 frontend/src/services/perfCollector.ts diff --git a/api/app.py b/api/app.py index b91b73a..c2fb880 100644 --- a/api/app.py +++ b/api/app.py @@ -9,6 +9,7 @@ from api.auth import get_current_user from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS, APP_ENV from api.decision_routes import decision_router from api.passkey_routes import passkey_router +from api.perf_routes import perf_router from api.poi_routes import poi_router from api.ws_routes import ws_router from api.rate_limit_config import RateLimitConfig @@ -105,6 +106,7 @@ app = FastAPI( openapi_url=None if APP_ENV == "production" else "/openapi.json", ) app.include_router(passkey_router) +app.include_router(perf_router) app.include_router(poi_router) app.include_router(decision_router) app.include_router(ws_router) diff --git a/api/metrics.py b/api/metrics.py index 51984ef..252f45d 100644 --- a/api/metrics.py +++ b/api/metrics.py @@ -56,6 +56,14 @@ celery_tasks_total: Counter celery_task_duration_seconds: Histogram celery_tasks_active: UpDownCounter +# --------------------------------------------------------------------------- +# Frontend performance metrics +# --------------------------------------------------------------------------- +frontend_worker_roundtrip: Histogram +frontend_worker_compute: Histogram +frontend_main_thread: Histogram +frontend_feature_count: Histogram + def init_metrics(service_name: str = "realestate-crawler") -> PrometheusMetricReader: """Initialise the OTel MeterProvider and define all instruments. @@ -70,6 +78,8 @@ def init_metrics(service_name: str = "realestate-crawler") -> PrometheusMetricRe global geojson_cache_operations global ocr_attempts, ocr_successes global celery_tasks_total, celery_task_duration_seconds, celery_tasks_active + global frontend_worker_roundtrip, frontend_worker_compute + global frontend_main_thread, frontend_feature_count if _reader is not None: return _reader @@ -144,6 +154,24 @@ def init_metrics(service_name: str = "realestate-crawler") -> PrometheusMetricRe description="Currently active Celery tasks", ) + # -- Frontend performance -- + frontend_worker_roundtrip = _meter.create_histogram( + "frontend_worker_roundtrip_seconds", + description="Browser worker message round-trip time", + ) + frontend_worker_compute = _meter.create_histogram( + "frontend_worker_compute_seconds", + description="Computation time inside the web worker", + ) + frontend_main_thread = _meter.create_histogram( + "frontend_main_thread_seconds", + description="Main-thread blocking operation duration", + ) + frontend_feature_count = _meter.create_histogram( + "frontend_feature_count", + description="Number of features per heatmap load", + ) + return _reader diff --git a/api/perf_routes.py b/api/perf_routes.py new file mode 100644 index 0000000..e5bcac2 --- /dev/null +++ b/api/perf_routes.py @@ -0,0 +1,43 @@ +"""Frontend performance metrics ingestion endpoint.""" +from __future__ import annotations + +from fastapi import APIRouter +from pydantic import BaseModel, Field, field_validator + +import api.metrics as app_metrics + +ALLOWED_METRICS = {"worker_roundtrip", "worker_compute", "main_thread", "feature_count"} +MAX_BATCH_SIZE = 100 + + +class PerfSample(BaseModel): + metric: str + operation: str = Field(max_length=50) + value: float = Field(ge=0, le=3600) + + @field_validator("metric") + @classmethod + def validate_metric(cls, v: str) -> str: + if v not in ALLOWED_METRICS: + raise ValueError(f"Unknown metric: {v}") + return v + + +perf_router = APIRouter(tags=["perf"]) + + +@perf_router.post("/api/perf", status_code=204) +async def record_perf(samples: list[PerfSample]) -> None: + if len(samples) > MAX_BATCH_SIZE: + samples = samples[:MAX_BATCH_SIZE] + + for s in samples: + attrs = {"operation": s.operation} + if s.metric == "worker_roundtrip": + app_metrics.frontend_worker_roundtrip.record(s.value, attrs) + elif s.metric == "worker_compute": + app_metrics.frontend_worker_compute.record(s.value, attrs) + elif s.metric == "main_thread": + app_metrics.frontend_main_thread.record(s.value, attrs) + elif s.metric == "feature_count": + app_metrics.frontend_feature_count.record(s.value) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 50c0567..b62c400 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,6 +5,9 @@ import './index.css'; import { AuthProvider } from "react-oidc-context"; import { oidcConfig } from './auth/config.ts'; +import { startCollector } from './services/perfCollector.ts'; + +startCollector(); createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index e3b11e0..ce41c82 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -7,3 +7,4 @@ export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from '. export { fetchUserPOIs, createPOI, updatePOI, deletePOI, triggerPOICalculation, fetchPOIDistances, fetchBulkPOIDistances } from './poiService'; export { fetchDecisions, setDecision, clearDecision } from './decisionService'; export { fetchListingDetail } from './listingDetailService'; +export { record as recordPerf, startCollector, stopCollector } from './perfCollector'; diff --git a/frontend/src/services/perfCollector.ts b/frontend/src/services/perfCollector.ts new file mode 100644 index 0000000..9048fbb --- /dev/null +++ b/frontend/src/services/perfCollector.ts @@ -0,0 +1,46 @@ +interface PerfSample { + metric: string; + operation: string; + value: number; +} + +const FLUSH_INTERVAL_MS = 30_000; +let batch: PerfSample[] = []; +let flushTimer: ReturnType | null = null; + +export function record(metric: string, operation: string, value: number): void { + batch.push({ metric, operation, value }); +} + +function flush(): void { + if (batch.length === 0) return; + const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' }); + batch = []; + + if (navigator.sendBeacon) { + navigator.sendBeacon('/api/perf', blob); + } else { + fetch('/api/perf', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: blob, + keepalive: true, + }).catch(() => {}); + } +} + +export function startCollector(): void { + if (flushTimer) return; + flushTimer = setInterval(flush, FLUSH_INTERVAL_MS); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') flush(); + }); +} + +export function stopCollector(): void { + if (flushTimer) { + clearInterval(flushTimer); + flushTimer = null; + } + flush(); +} diff --git a/frontend/src/workers/HexgridHeatmapClient.ts b/frontend/src/workers/HexgridHeatmapClient.ts index 01fe7d5..49d3a6b 100644 --- a/frontend/src/workers/HexgridHeatmapClient.ts +++ b/frontend/src/workers/HexgridHeatmapClient.ts @@ -7,6 +7,7 @@ import type { GridResultMessage, } from './types'; import { toFeatureItem } from './types'; +import { record } from '../services/perfCollector'; interface HexgridConfig { intensity: number; @@ -46,6 +47,9 @@ export class HexgridHeatmapClient { private pendingColorScale = new Map>(); private pendingBounds = new Map>(); + // Send timestamps for round-trip measurement + private sendTimestamps = new Map(); + private config: HexgridConfig = { intensity: 8, spread: 0.1, @@ -118,9 +122,15 @@ export class HexgridHeatmapClient { private handleWorkerMessage(msg: WorkerResponse): void { switch (msg.type) { - case 'DATA_READY': - // Data loaded in worker, nothing to do on main thread + case 'DATA_READY': { + const sent = this.sendTimestamps.get(msg.requestId); + if (sent !== undefined) { + this.sendTimestamps.delete(msg.requestId); + record('worker_roundtrip', 'set_data', (performance.now() - sent) / 1000); + } + record('worker_compute', 'set_data', msg.computeMs / 1000); break; + } case 'GRID_RESULT': this.handleGridResult(msg); @@ -151,6 +161,14 @@ export class HexgridHeatmapClient { if (msg.requestId !== this.pendingGridRequestId) return; this.pendingGridRequestId = null; + // Record perf metrics + const sent = this.sendTimestamps.get(msg.requestId); + if (sent !== undefined) { + this.sendTimestamps.delete(msg.requestId); + record('worker_roundtrip', 'generate_grid', (performance.now() - sent) / 1000); + } + record('worker_compute', 'generate_grid', msg.computeMs / 1000); + const hexgrid = msg.hexgrid as unknown as GeoJSON.FeatureCollection; const currentZoom = Math.floor(this.map.getZoom()); const zoomChanged = this.lastZoom !== null && this.lastZoom !== currentZoom; @@ -198,12 +216,16 @@ export class HexgridHeatmapClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any setData(data: { features: Array<{ properties: any; geometry: { type: 'Point'; coordinates: [number, number] } }> }): void { // Build main-thread R-tree for click queries + const t0 = performance.now(); const items = data.features.map(toFeatureItem); this.mainTree.clear(); this.mainTree.load(items); + record('main_thread', 'rtree_build', (performance.now() - t0) / 1000); + record('feature_count', 'set_data', data.features.length); // Post to worker for grid generation tree const requestId = this.nextRequestId(); + this.sendTimestamps.set(requestId, performance.now()); this.worker.postMessage({ type: 'SET_DATA', requestId, @@ -246,8 +268,20 @@ export class HexgridHeatmapClient { percentileConfig: { minBound: number; maxBound: number } ): Promise { const requestId = this.nextRequestId(); + this.sendTimestamps.set(requestId, performance.now()); return new Promise((resolve, reject) => { - this.pendingColorScale.set(requestId, { resolve, reject }); + this.pendingColorScale.set(requestId, { + resolve: (msg) => { + const sent = this.sendTimestamps.get(requestId); + if (sent !== undefined) { + this.sendTimestamps.delete(requestId); + record('worker_roundtrip', 'color_scale', (performance.now() - sent) / 1000); + } + record('worker_compute', 'color_scale', msg.computeMs / 1000); + resolve(msg); + }, + reject, + }); this.worker.postMessage({ type: 'COMPUTE_COLOR_SCALE', requestId, @@ -261,8 +295,20 @@ export class HexgridHeatmapClient { boundsPercentileConfig: { clipMin: number; clipMax: number } ): Promise { const requestId = this.nextRequestId(); + this.sendTimestamps.set(requestId, performance.now()); return new Promise((resolve, reject) => { - this.pendingBounds.set(requestId, { resolve, reject }); + this.pendingBounds.set(requestId, { + resolve: (msg) => { + const sent = this.sendTimestamps.get(requestId); + if (sent !== undefined) { + this.sendTimestamps.delete(requestId); + record('worker_roundtrip', 'bounds', (performance.now() - sent) / 1000); + } + record('worker_compute', 'bounds', msg.computeMs / 1000); + resolve(msg); + }, + reject, + }); this.worker.postMessage({ type: 'COMPUTE_BOUNDS', requestId, @@ -302,6 +348,7 @@ export class HexgridHeatmapClient { const requestId = this.nextRequestId(); this.pendingGridRequestId = requestId; + this.sendTimestamps.set(requestId, performance.now()); this.worker.postMessage({ type: 'GENERATE_GRID', diff --git a/frontend/src/workers/hexgrid.worker.ts b/frontend/src/workers/hexgrid.worker.ts index 71c5d65..e0a7e83 100644 --- a/frontend/src/workers/hexgrid.worker.ts +++ b/frontend/src/workers/hexgrid.worker.ts @@ -34,6 +34,7 @@ function reduceAverage(data: number[]): number { } function generateGrid(msg: GenerateGridMessage): void { + const t0 = performance.now(); const { requestId, zoom, bounds, config } = msg; const { cellDensity, spread, propertyName } = config; @@ -115,11 +116,13 @@ function generateGrid(msg: GenerateGridMessage): void { self.postMessage({ type: 'GRID_RESULT', requestId, + computeMs: performance.now() - t0, hexgrid: { type: 'FeatureCollection', features: cellsToSave }, }); } function computeColorScale(msg: ComputeColorScaleMessage): void { + const t0 = performance.now(); const { requestId, metricMode, percentileConfig } = msg; const values: number[] = []; @@ -143,6 +146,7 @@ function computeColorScale(msg: ComputeColorScaleMessage): void { self.postMessage({ type: 'COLOR_SCALE_RESULT', requestId, + computeMs: performance.now() - t0, min, max, hasValues: true, @@ -151,6 +155,7 @@ function computeColorScale(msg: ComputeColorScaleMessage): void { self.postMessage({ type: 'COLOR_SCALE_RESULT', requestId, + computeMs: performance.now() - t0, min: 0, max: 1, hasValues: false, @@ -159,6 +164,7 @@ function computeColorScale(msg: ComputeColorScaleMessage): void { } function computeBounds(msg: ComputeBoundsMessage): void { + const t0 = performance.now(); const { requestId, boundsPercentileConfig } = msg; const lngs: number[] = []; @@ -178,6 +184,7 @@ function computeBounds(msg: ComputeBoundsMessage): void { self.postMessage({ type: 'BOUNDS_RESULT', requestId, + computeMs: performance.now() - t0, minLng: percentile(lngs, clipMin), maxLng: percentile(lngs, clipMax), minLat: percentile(lats, clipMin), @@ -205,6 +212,7 @@ self.onmessage = (e: MessageEvent) => { switch (msg.type) { case 'SET_DATA': { + const t0 = performance.now(); features = msg.features.map(toFeatureItem); tree.clear(); tree.load(features); @@ -213,6 +221,7 @@ self.onmessage = (e: MessageEvent) => { type: 'DATA_READY', requestId: msg.requestId, featureCount: features.length, + computeMs: performance.now() - t0, }); break; } diff --git a/frontend/src/workers/types.ts b/frontend/src/workers/types.ts index 0f08155..b86ab6a 100644 --- a/frontend/src/workers/types.ts +++ b/frontend/src/workers/types.ts @@ -70,11 +70,13 @@ export interface DataReadyMessage { type: 'DATA_READY'; requestId: number; featureCount: number; + computeMs: number; } export interface GridResultMessage { type: 'GRID_RESULT'; requestId: number; + computeMs: number; hexgrid: { type: 'FeatureCollection'; features: Array<{ @@ -94,6 +96,7 @@ export interface GridResultMessage { export interface ColorScaleResultMessage { type: 'COLOR_SCALE_RESULT'; requestId: number; + computeMs: number; min: number; max: number; hasValues: boolean; @@ -102,6 +105,7 @@ export interface ColorScaleResultMessage { export interface BoundsResultMessage { type: 'BOUNDS_RESULT'; requestId: number; + computeMs: number; minLng: number; maxLng: number; minLat: number; diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 6d0ac29..0a12a15 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/appsidebar.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/activequery.tsx","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/favoritesview.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/listingdetail.tsx","./src/components/listingdetailsheet.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/mobilebottomsheet.tsx","./src/components/mobilemenu.tsx","./src/components/poimanager.tsx","./src/components/photocarousel.tsx","./src/components/propertycard.tsx","./src/components/propertycardcompact.tsx","./src/components/savedview.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/swipecard.tsx","./src/components/swipereviewmode.tsx","./src/components/swipeablecardrow.tsx","./src/components/swipeablepropertycard.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usedecisions.ts","./src/hooks/uselistingdetail.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/decisionservice.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingdetailservice.ts","./src/services/listingservice.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts","./src/workers/hexgridheatmapclient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/appsidebar.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/activequery.tsx","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/favoritesview.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/listingdetail.tsx","./src/components/listingdetailsheet.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/mobilebottomsheet.tsx","./src/components/mobilemenu.tsx","./src/components/poimanager.tsx","./src/components/photocarousel.tsx","./src/components/propertycard.tsx","./src/components/propertycardcompact.tsx","./src/components/savedview.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/swipecard.tsx","./src/components/swipereviewmode.tsx","./src/components/swipeablecardrow.tsx","./src/components/swipeablepropertycard.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usedecisions.ts","./src/hooks/uselistingdetail.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/decisionservice.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingdetailservice.ts","./src/services/listingservice.ts","./src/services/perfcollector.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts","./src/workers/hexgridheatmapclient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"} \ No newline at end of file