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.
This commit is contained in:
parent
c24c3a545c
commit
d90fa38776
10 changed files with 188 additions and 5 deletions
|
|
@ -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(
|
||||
<StrictMode>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
46
frontend/src/services/perfCollector.ts
Normal file
46
frontend/src/services/perfCollector.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
interface PerfSample {
|
||||
metric: string;
|
||||
operation: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const FLUSH_INTERVAL_MS = 30_000;
|
||||
let batch: PerfSample[] = [];
|
||||
let flushTimer: ReturnType<typeof setInterval> | 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();
|
||||
}
|
||||
|
|
@ -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<number, PendingPromise<ColorScaleResultMessage>>();
|
||||
private pendingBounds = new Map<number, PendingPromise<BoundsResultMessage>>();
|
||||
|
||||
// Send timestamps for round-trip measurement
|
||||
private sendTimestamps = new Map<number, number>();
|
||||
|
||||
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<ColorScaleResultMessage> {
|
||||
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<BoundsResultMessage> {
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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<WorkerRequest>) => {
|
|||
|
||||
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<WorkerRequest>) => {
|
|||
type: 'DATA_READY',
|
||||
requestId: msg.requestId,
|
||||
featureCount: features.length,
|
||||
computeMs: performance.now() - t0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue