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

@ -440,8 +440,10 @@ async def _stream_from_db(
async def _repopulate_cache_background(query_parameters: QueryParameters) -> None: async def _repopulate_cache_background(query_parameters: QueryParameters) -> None:
"""Repopulate the cache from DB in the background (fire-and-forget).""" """Repopulate the cache from DB in the background (fire-and-forget)."""
if not acquire_repopulation_lock(query_parameters): if not acquire_repopulation_lock(query_parameters):
app_metrics.cache_repopulation_total.add(1, {"result": "skipped"})
logger.debug("Skipping background repopulation — already in progress") logger.debug("Skipping background repopulation — already in progress")
return return
app_metrics.cache_repopulation_total.add(1, {"result": "started"})
try: try:
logger.info("Starting background cache repopulation for stale entry") logger.info("Starting background cache repopulation for stale entry")
repository = ListingRepository(engine) repository = ListingRepository(engine)
@ -453,14 +455,46 @@ async def _repopulate_cache_background(query_parameters: QueryParameters) -> Non
feature = convert_row_to_geojson(row, query_parameters.listing_type.value) feature = convert_row_to_geojson(row, query_parameters.listing_type.value)
cache_features_batch_staged(staging_key, [feature]) cache_features_batch_staged(staging_key, [feature])
finalize_cache_population(staging_key, query_parameters) finalize_cache_population(staging_key, query_parameters)
app_metrics.cache_repopulation_total.add(1, {"result": "completed"})
logger.info("Background cache repopulation completed") logger.info("Background cache repopulation completed")
except Exception: except Exception:
delete_staging_key(staging_key) delete_staging_key(staging_key)
raise raise
except Exception: except Exception:
app_metrics.cache_repopulation_total.add(1, {"result": "failed"})
logger.exception("Background cache repopulation failed") logger.exception("Background cache repopulation failed")
async def _instrumented_stream(
inner: AsyncGenerator[str, None],
source: str,
) -> AsyncGenerator[str, None]:
"""Wrap a streaming generator to record TTFB, total duration, and feature count."""
t0 = time.monotonic()
first_yielded = False
feature_count = 0
try:
async for chunk in inner:
if not first_yielded:
app_metrics.stream_time_to_first_byte_seconds.record(
time.monotonic() - t0, {"source": source}
)
first_yielded = True
# Count features from batch messages
try:
msg = json.loads(chunk)
if msg.get("type") == "batch" and "features" in msg:
feature_count += len(msg["features"])
except (json.JSONDecodeError, TypeError):
pass
yield chunk
finally:
duration = time.monotonic() - t0
app_metrics.stream_total_duration_seconds.record(duration, {"source": source})
app_metrics.stream_features_total.add(feature_count, {"source": source})
app_metrics.stream_requests_total.add(1, {"source": source})
@app.get("/api/listing_geojson/stream") @app.get("/api/listing_geojson/stream")
async def stream_listing_geojson( async def stream_listing_geojson(
user: Annotated[User, Depends(get_current_user)], user: Annotated[User, Depends(get_current_user)],
@ -501,21 +535,28 @@ async def stream_listing_geojson(
app_metrics.geojson_cache_operations.add(1, {"result": "hit"}) app_metrics.geojson_cache_operations.add(1, {"result": "hit"})
stale = is_cache_stale(query_parameters) stale = is_cache_stale(query_parameters)
if stale: if stale:
app_metrics.cache_stale_serves_total.add(1)
# Fire-and-forget background repopulation # Fire-and-forget background repopulation
asyncio.create_task(_repopulate_cache_background(query_parameters)) asyncio.create_task(_repopulate_cache_background(query_parameters))
generator = _stream_from_cache( generator = _instrumented_stream(
query_parameters, batch_size, limit, _stream_from_cache(
user_email=user.email, query_parameters, batch_size, limit,
decision_filter=decision_filter, user_email=user.email,
stale=stale, decision_filter=decision_filter,
stale=stale,
),
source="cache",
) )
else: else:
app_metrics.geojson_cache_operations.add(1, {"result": "miss"}) app_metrics.geojson_cache_operations.add(1, {"result": "miss"})
generator = _stream_from_db( generator = _instrumented_stream(
query_parameters, batch_size, limit, poi_distances_lookup, _stream_from_db(
skip_cache=include_poi_distances, query_parameters, batch_size, limit, poi_distances_lookup,
user_email=user.email, skip_cache=include_poi_distances,
decision_filter=decision_filter, user_email=user.email,
decision_filter=decision_filter,
),
source="db",
) )
return StreamingResponse( return StreamingResponse(
@ -660,9 +701,13 @@ async def get_listing_detail(
"""Get detailed information for a single listing.""" """Get detailed information for a single listing."""
repository = ListingRepository(engine) repository = ListingRepository(engine)
lt = ListingType(listing_type) lt = ListingType(listing_type)
t_step = time.monotonic()
listings = await repository.get_listings( listings = await repository.get_listings(
only_ids=[listing_id], listing_type=lt only_ids=[listing_id], listing_type=lt
) )
app_metrics.listing_detail_step_duration_seconds.record(
time.monotonic() - t_step, {"step": "fetch_listing"}
)
if not listings: if not listings:
raise HTTPException(status_code=404, detail="Listing not found") raise HTTPException(status_code=404, detail="Listing not found")
@ -737,6 +782,7 @@ async def get_listing_detail(
furnish_type_val = str(ft) furnish_type_val = str(ft)
# Load user's decision for this listing # Load user's decision for this listing
t_step = time.monotonic()
decision_val: str | None = None decision_val: str | None = None
user_id = _get_user_id_safe(user.email) user_id = _get_user_id_safe(user.email)
if user_id is not None: if user_id is not None:
@ -746,8 +792,12 @@ async def get_listing_detail(
if d.listing_id == listing_id and d.listing_type == listing_type: if d.listing_id == listing_id and d.listing_type == listing_type:
decision_val = d.decision decision_val = d.decision
break break
app_metrics.listing_detail_step_duration_seconds.record(
time.monotonic() - t_step, {"step": "load_decision"}
)
# Load POI distances # Load POI distances
t_step = time.monotonic()
poi_distances_list: list[dict] = [] poi_distances_list: list[dict] = []
if user_id is not None: if user_id is not None:
poi_repo = POIRepository(engine) poi_repo = POIRepository(engine)
@ -765,6 +815,9 @@ async def get_listing_detail(
"duration_seconds": d.duration_seconds, "duration_seconds": d.duration_seconds,
"distance_meters": d.distance_meters, "distance_meters": d.distance_meters,
}) })
app_metrics.listing_detail_step_duration_seconds.record(
time.monotonic() - t_step, {"step": "load_poi_distances"}
)
return ListingDetailResponse( return ListingDetailResponse(
id=listing.id, id=listing.id,

View file

@ -64,6 +64,40 @@ frontend_worker_compute: Histogram
frontend_main_thread: Histogram frontend_main_thread: Histogram
frontend_feature_count: Histogram frontend_feature_count: Histogram
# ---------------------------------------------------------------------------
# Database query metrics
# ---------------------------------------------------------------------------
db_query_duration_seconds: Histogram
db_query_rows_returned: Histogram
# ---------------------------------------------------------------------------
# Streaming lifecycle metrics
# ---------------------------------------------------------------------------
stream_time_to_first_byte_seconds: Histogram
stream_total_duration_seconds: Histogram
stream_features_total: Counter
stream_requests_total: Counter
# ---------------------------------------------------------------------------
# Cache performance metrics
# ---------------------------------------------------------------------------
cache_operation_duration_seconds: Histogram
cache_repopulation_total: Counter
cache_stale_serves_total: Counter
# ---------------------------------------------------------------------------
# Listing detail metrics
# ---------------------------------------------------------------------------
listing_detail_step_duration_seconds: Histogram
# ---------------------------------------------------------------------------
# Frontend navigation/usage metrics
# ---------------------------------------------------------------------------
frontend_page_load: Histogram
frontend_time_to_first_listing: Histogram
frontend_stream_download: Histogram
frontend_listing_detail_load: Histogram
def init_metrics(service_name: str = "realestate-crawler") -> PrometheusMetricReader: def init_metrics(service_name: str = "realestate-crawler") -> PrometheusMetricReader:
"""Initialise the OTel MeterProvider and define all instruments. """Initialise the OTel MeterProvider and define all instruments.
@ -80,6 +114,14 @@ def init_metrics(service_name: str = "realestate-crawler") -> PrometheusMetricRe
global celery_tasks_total, celery_task_duration_seconds, celery_tasks_active global celery_tasks_total, celery_task_duration_seconds, celery_tasks_active
global frontend_worker_roundtrip, frontend_worker_compute global frontend_worker_roundtrip, frontend_worker_compute
global frontend_main_thread, frontend_feature_count global frontend_main_thread, frontend_feature_count
global db_query_duration_seconds, db_query_rows_returned
global stream_time_to_first_byte_seconds, stream_total_duration_seconds
global stream_features_total, stream_requests_total
global cache_operation_duration_seconds, cache_repopulation_total
global cache_stale_serves_total
global listing_detail_step_duration_seconds
global frontend_page_load, frontend_time_to_first_listing
global frontend_stream_download, frontend_listing_detail_load
if _reader is not None: if _reader is not None:
return _reader return _reader
@ -172,9 +214,93 @@ def init_metrics(service_name: str = "realestate-crawler") -> PrometheusMetricRe
description="Number of features per heatmap load", description="Number of features per heatmap load",
) )
# -- Database query timing --
db_query_duration_seconds = _meter.create_histogram(
"db_query_duration_seconds",
description="Duration of individual database queries in seconds",
)
db_query_rows_returned = _meter.create_histogram(
"db_query_rows_returned",
description="Number of rows returned per database query",
)
# -- Streaming lifecycle --
stream_time_to_first_byte_seconds = _meter.create_histogram(
"stream_time_to_first_byte_seconds",
description="Time from handler entry to first NDJSON line",
)
stream_total_duration_seconds = _meter.create_histogram(
"stream_total_duration_seconds",
description="Total wall-clock time for a streaming response",
)
stream_features_total = _meter.create_counter(
"stream_features_total",
description="Total GeoJSON features streamed to clients",
)
stream_requests_total = _meter.create_counter(
"stream_requests_total",
description="Total streaming requests served",
)
# -- Cache performance --
cache_operation_duration_seconds = _meter.create_histogram(
"cache_operation_duration_seconds",
description="Redis cache operation latency in seconds",
)
cache_repopulation_total = _meter.create_counter(
"cache_repopulation_total",
description="Cache repopulation events by result",
)
cache_stale_serves_total = _meter.create_counter(
"cache_stale_serves_total",
description="Number of times stale cache was served during repopulation",
)
# -- Listing detail --
listing_detail_step_duration_seconds = _meter.create_histogram(
"listing_detail_step_duration_seconds",
description="Per-step timing in listing detail endpoint",
)
# -- Frontend navigation/usage --
frontend_page_load = _meter.create_histogram(
"frontend_page_load_seconds",
description="Full page or filter load to data rendered",
)
frontend_time_to_first_listing = _meter.create_histogram(
"frontend_time_to_first_listing_seconds",
description="Time from load trigger to first listing batch on screen",
)
frontend_stream_download = _meter.create_histogram(
"frontend_stream_download_seconds",
description="Client-side total stream download duration",
)
frontend_listing_detail_load = _meter.create_histogram(
"frontend_listing_detail_load_seconds",
description="Time from click to listing detail data rendered",
)
return _reader return _reader
def record_db_query(
operation: str,
model: str,
duration: float,
rows: int | None = None,
) -> None:
"""Record a database query timing metric.
Safe to call even when ``init_metrics()`` has not been called (e.g.
from CLI usage) silently no-ops in that case.
"""
if _meter is None:
return
db_query_duration_seconds.record(duration, {"operation": operation, "model": model})
if rows is not None:
db_query_rows_returned.record(rows, {"operation": operation})
def get_metrics_asgi_app(): # type: ignore[no-untyped-def] def get_metrics_asgi_app(): # type: ignore[no-untyped-def]
"""Return the Prometheus ASGI app for mounting at /metrics.""" """Return the Prometheus ASGI app for mounting at /metrics."""
return make_asgi_app() return make_asgi_app()

View file

@ -6,7 +6,10 @@ from pydantic import BaseModel, Field, field_validator
import api.metrics as app_metrics import api.metrics as app_metrics
ALLOWED_METRICS = {"worker_roundtrip", "worker_compute", "main_thread", "feature_count"} ALLOWED_METRICS = {
"worker_roundtrip", "worker_compute", "main_thread", "feature_count",
"page_load", "time_to_first_listing", "stream_download", "listing_detail_load",
}
MAX_BATCH_SIZE = 100 MAX_BATCH_SIZE = 100
@ -41,5 +44,13 @@ async def record_perf(samples: list[PerfSample]) -> Response:
app_metrics.frontend_main_thread.record(s.value, attrs) app_metrics.frontend_main_thread.record(s.value, attrs)
elif s.metric == "feature_count": elif s.metric == "feature_count":
app_metrics.frontend_feature_count.record(s.value) app_metrics.frontend_feature_count.record(s.value)
elif s.metric == "page_load":
app_metrics.frontend_page_load.record(s.value, attrs)
elif s.metric == "time_to_first_listing":
app_metrics.frontend_time_to_first_listing.record(s.value, attrs)
elif s.metric == "stream_download":
app_metrics.frontend_stream_download.record(s.value)
elif s.metric == "listing_detail_load":
app_metrics.frontend_listing_detail_load.record(s.value)
return Response(status_code=204) return Response(status_code=204)

View file

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

View file

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

View file

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

View file

@ -1847,8 +1847,8 @@
"uid": "PBFA97CFB590B2093" "uid": "PBFA97CFB590B2093"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "sum(rate(http_server_duration_milliseconds_count{job=\"realestate-crawler-api\"}[5m])) by (http_target)", "expr": "sum(rate(http_server_duration_milliseconds_count{job=\"realestate-crawler-api\"}[5m])) by (http_server_name)",
"legendFormat": "{{ http_target }}", "legendFormat": "{{ http_server_name }}",
"range": true, "range": true,
"refId": "A" "refId": "A"
} }
@ -2062,8 +2062,8 @@
"uid": "PBFA97CFB590B2093" "uid": "PBFA97CFB590B2093"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "sum(http_server_active_requests{job=\"realestate-crawler-api\"}) by (http_target)", "expr": "sum(http_server_active_requests{job=\"realestate-crawler-api\"}) by (http_server_name)",
"legendFormat": "{{ http_target }}", "legendFormat": "{{ http_server_name }}",
"range": true, "range": true,
"refId": "A" "refId": "A"
} }
@ -2583,6 +2583,954 @@
], ],
"title": "Feature Count Over Time", "title": "Feature Count Over Time",
"type": "timeseries" "type": "timeseries"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 69 },
"id": 200,
"panels": [],
"title": "Database Query Performance",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Database query duration by operation (p50/p95)",
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 70 },
"id": 201,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.50, sum(rate(db_query_duration_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, operation))",
"legendFormat": "p50 {{ operation }}",
"range": true,
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum(rate(db_query_duration_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, operation))",
"legendFormat": "p95 {{ operation }}",
"range": true,
"refId": "B"
}
],
"title": "DB Query Duration by Operation (p50/p95)",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Average query duration by operation — highlights top offenders",
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.1 },
{ "color": "red", "value": 0.5 }
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 6, "x": 12, "y": 70 },
"id": 202,
"options": {
"displayMode": "gradient",
"maxVizHeight": 300,
"minVizHeight": 16,
"minVizWidth": 8,
"namePlacement": "auto",
"orientation": "horizontal",
"reduceOptions": { "calcs": ["mean"], "fields": "", "values": false },
"showUnfilled": true,
"sizing": "auto",
"valueMode": "color"
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "rate(db_query_duration_seconds_sum{job=\"realestate-crawler-api\"}[5m]) / rate(db_query_duration_seconds_count{job=\"realestate-crawler-api\"}[5m])",
"legendFormat": "{{ operation }}",
"range": true,
"refId": "A"
}
],
"title": "Top Offenders (Avg Duration)",
"type": "bargauge"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Rows returned per query (avg/p95)",
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"min": 0,
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 6, "x": 18, "y": 70 },
"id": 203,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "rate(db_query_rows_returned_sum{job=\"realestate-crawler-api\"}[5m]) / rate(db_query_rows_returned_count{job=\"realestate-crawler-api\"}[5m])",
"legendFormat": "avg {{ operation }}",
"range": true,
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum(rate(db_query_rows_returned_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, operation))",
"legendFormat": "p95 {{ operation }}",
"range": true,
"refId": "B"
}
],
"title": "Rows Returned per Query (avg/p95)",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 78 },
"id": 210,
"panels": [],
"title": "Streaming Performance",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Time to first byte by stream source (cache vs db)",
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 79 },
"id": 211,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.50, sum(rate(stream_time_to_first_byte_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, source))",
"legendFormat": "p50 {{ source }}",
"range": true,
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum(rate(stream_time_to_first_byte_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, source))",
"legendFormat": "p95 {{ source }}",
"range": true,
"refId": "B"
}
],
"title": "Stream TTFB by Source (p50/p95)",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Total stream duration by source",
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 79 },
"id": 212,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.50, sum(rate(stream_total_duration_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, source))",
"legendFormat": "p50 {{ source }}",
"range": true,
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum(rate(stream_total_duration_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, source))",
"legendFormat": "p95 {{ source }}",
"range": true,
"refId": "B"
}
],
"title": "Stream Total Duration by Source (p50/p95)",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Average features per streaming response",
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "blue", "value": null }
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 4, "w": 4, "x": 16, "y": 79 },
"id": 213,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "rate(stream_features_total{job=\"realestate-crawler-api\"}[5m]) / rate(stream_requests_total{job=\"realestate-crawler-api\"}[5m])",
"legendFormat": "{{ source }}",
"range": true,
"refId": "A"
}
],
"title": "Avg Features per Stream",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Cache vs DB stream request ratio",
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 4, "w": 4, "x": 20, "y": 79 },
"id": 214,
"options": {
"displayLabels": ["percent"],
"legend": { "displayMode": "list", "placement": "bottom", "showLegend": true },
"pieType": "donut",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"tooltip": { "mode": "multi", "sort": "none" }
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "increase(stream_requests_total{job=\"realestate-crawler-api\"}[1h])",
"legendFormat": "{{ source }}",
"range": true,
"refId": "A"
}
],
"title": "Cache vs DB Stream Ratio",
"type": "piechart"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Total stream requests over time",
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "green", "value": null }]
},
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 83 },
"id": 215,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "increase(stream_requests_total{job=\"realestate-crawler-api\"}[5m])",
"legendFormat": "{{ source }}",
"range": true,
"refId": "A"
}
],
"title": "Stream Requests (5m increase)",
"type": "stat"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 87 },
"id": 220,
"panels": [],
"title": "Listing Detail Breakdown",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Per-step timing in listing detail endpoint (p50/p95)",
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 88 },
"id": 221,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.50, sum(rate(listing_detail_step_duration_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, step))",
"legendFormat": "p50 {{ step }}",
"range": true,
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum(rate(listing_detail_step_duration_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, step))",
"legendFormat": "p95 {{ step }}",
"range": true,
"refId": "B"
}
],
"title": "Listing Detail Step Duration (p50/p95)",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Average duration by step — stacked to show total breakdown",
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "bars",
"fillOpacity": 80,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "normal" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 88 },
"id": 222,
"options": {
"legend": { "calcs": ["mean"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "rate(listing_detail_step_duration_seconds_sum{job=\"realestate-crawler-api\"}[5m]) / rate(listing_detail_step_duration_seconds_count{job=\"realestate-crawler-api\"}[5m])",
"legendFormat": "{{ step }}",
"range": true,
"refId": "A"
}
],
"title": "Listing Detail Stacked Avg by Step",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 96 },
"id": 230,
"panels": [],
"title": "Cache Performance",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Redis cache operation latency by operation type (p50/p95)",
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 10, "x": 0, "y": 97 },
"id": 231,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.50, sum(rate(cache_operation_duration_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, operation))",
"legendFormat": "p50 {{ operation }}",
"range": true,
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum(rate(cache_operation_duration_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, operation))",
"legendFormat": "p95 {{ operation }}",
"range": true,
"refId": "B"
}
],
"title": "Cache Operation Latency (p50/p95)",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Cache repopulation events by result",
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "bars",
"fillOpacity": 80,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "normal" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 8, "x": 10, "y": 97 },
"id": 232,
"options": {
"legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "increase(cache_repopulation_total{job=\"realestate-crawler-api\"}[1h])",
"legendFormat": "{{ result }}",
"range": true,
"refId": "A"
}
],
"title": "Cache Repopulation Events",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Number of times stale cache was served during background repopulation",
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 10 },
{ "color": "red", "value": 50 }
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 6, "x": 18, "y": 97 },
"id": 233,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "increase(cache_stale_serves_total{job=\"realestate-crawler-api\"}[1h])",
"legendFormat": "Stale Serves",
"range": true,
"refId": "A"
}
],
"title": "Stale Cache Serves (1h)",
"type": "stat"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 105 },
"id": 240,
"panels": [],
"title": "Frontend Navigation Metrics",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Full page or filter load time (p50/p95)",
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 6, "x": 0, "y": 106 },
"id": 241,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.50, sum(rate(frontend_page_load_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, operation))",
"legendFormat": "p50 {{ operation }}",
"range": true,
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum(rate(frontend_page_load_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, operation))",
"legendFormat": "p95 {{ operation }}",
"range": true,
"refId": "B"
}
],
"title": "Page Load Time (p50/p95)",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Time to first listing batch on screen (p50/p95)",
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 6, "x": 6, "y": 106 },
"id": 242,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.50, sum(rate(frontend_time_to_first_listing_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, operation))",
"legendFormat": "p50 {{ operation }}",
"range": true,
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum(rate(frontend_time_to_first_listing_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le, operation))",
"legendFormat": "p95 {{ operation }}",
"range": true,
"refId": "B"
}
],
"title": "Time to First Listing (p50/p95)",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Client-side total stream download duration (p50/p95)",
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 6, "x": 12, "y": 106 },
"id": 243,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.50, sum(rate(frontend_stream_download_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le))",
"legendFormat": "p50",
"range": true,
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum(rate(frontend_stream_download_seconds_bucket{job=\"realestate-crawler-api\"}[5m])) by (le))",
"legendFormat": "p95",
"range": true,
"refId": "B"
}
],
"title": "Stream Download Duration (p50/p95)",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"description": "Time from listing click to detail data rendered",
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 3 }
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 6, "x": 18, "y": 106 },
"id": 244,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["mean"], "fields": "", "values": false },
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.3.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"editorMode": "code",
"expr": "rate(frontend_listing_detail_load_seconds_sum{job=\"realestate-crawler-api\"}[5m]) / rate(frontend_listing_detail_load_seconds_count{job=\"realestate-crawler-api\"}[5m])",
"legendFormat": "Avg Detail Load",
"range": true,
"refId": "A"
}
],
"title": "Listing Detail Load Time",
"type": "stat"
} }
], ],
"preload": false, "preload": false,

View file

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

View file

@ -1,6 +1,8 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import time
from typing import Any, Generator from typing import Any, Generator
from api.metrics import record_db_query
from data_access import Listing from data_access import Listing
from models.listing import ( from models.listing import (
BuyListing, BuyListing,
@ -55,8 +57,10 @@ class ListingRepository:
if limit: if limit:
query = query.limit(limit) query = query.limit(limit)
t0 = time.monotonic()
with Session(self.engine) as session: with Session(self.engine) as session:
rows = list(session.exec(query).all()) 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") logging.debug(f"Found {len(rows)} listings")
return rows return rows
@ -110,8 +114,11 @@ class ListingRepository:
query = sa_select(func.count(model.id)) query = sa_select(func.count(model.id))
query = self._apply_query_filters(query, model, query_parameters) query = self._apply_query_filters(query, model, query_parameters)
t0 = time.monotonic()
with Session(self.engine) as session: 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( def stream_listings_optimized(
self, self,
@ -157,8 +164,10 @@ class ListingRepository:
batch_limit = min(page_size, limit - total_yielded) batch_limit = min(page_size, limit - total_yielded)
query = query.order_by(model.id).limit(batch_limit) query = query.order_by(model.id).limit(batch_limit)
t0 = time.monotonic()
with Session(self.engine) as session: with Session(self.engine) as session:
results = session.execute(query).fetchall() results = session.execute(query).fetchall()
record_db_query("stream_listings_page", model.__tablename__, time.monotonic() - t0, len(results))
if not results: if not results:
break break
@ -364,6 +373,7 @@ class ListingRepository:
Checks both RentListing and BuyListing tables and returns the latest. Checks both RentListing and BuyListing tables and returns the latest.
""" """
t0 = time.monotonic()
with Session(self.engine) as session: with Session(self.engine) as session:
rent_max = session.execute( rent_max = session.execute(
sa_select(func.max(RentListing.last_seen)) sa_select(func.max(RentListing.last_seen))
@ -371,6 +381,7 @@ class ListingRepository:
buy_max = session.execute( buy_max = session.execute(
sa_select(func.max(BuyListing.last_seen)) sa_select(func.max(BuyListing.last_seen))
).scalar() ).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] candidates = [t for t in (rent_max, buy_max) if t is not None]
return max(candidates) if candidates else None return max(candidates) if candidates else None
@ -385,9 +396,12 @@ class ListingRepository:
filtering against API results. filtering against API results.
""" """
model = RentListing if listing_type == ListingType.RENT else BuyListing model = RentListing if listing_type == ListingType.RENT else BuyListing
t0 = time.monotonic()
with Session(self.engine) as session: with Session(self.engine) as session:
result = session.execute(sa_select(model.id)) 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: async def mark_seen(self, listing_id: int, listing_type: ListingType = ListingType.RENT) -> None:
query_params = QueryParameters(listing_type=listing_type) 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.listing import ListingType
from models.poi import PointOfInterest from models.poi import PointOfInterest
from models.poi_distance import POIDistance from models.poi_distance import POIDistance
@ -12,11 +14,14 @@ class POIRepository:
self.engine = engine self.engine = engine
def get_pois_for_user(self, user_id: int) -> list[PointOfInterest]: def get_pois_for_user(self, user_id: int) -> list[PointOfInterest]:
t0 = time.monotonic()
with Session(self.engine) as session: with Session(self.engine) as session:
statement = select(PointOfInterest).where( statement = select(PointOfInterest).where(
PointOfInterest.user_id == user_id 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: def get_poi_by_id(self, poi_id: int) -> PointOfInterest | None:
with Session(self.engine) as session: with Session(self.engine) as session:
@ -55,6 +60,7 @@ class POIRepository:
"""Insert or update POI distances, handling duplicate unique constraints.""" """Insert or update POI distances, handling duplicate unique constraints."""
if not distances: if not distances:
return return
t0 = time.monotonic()
with Session(self.engine) as session: with Session(self.engine) as session:
dialect = self.engine.dialect.name dialect = self.engine.dialect.name
for dist in distances: for dist in distances:
@ -88,6 +94,7 @@ class POIRepository:
) )
session.execute(stmt) session.execute(stmt)
session.commit() session.commit()
record_db_query("upsert_distances", "poi_distance", time.monotonic() - t0, len(distances))
def get_distances_for_listings( def get_distances_for_listings(
self, self,
@ -95,6 +102,7 @@ class POIRepository:
listing_type: ListingType, listing_type: ListingType,
user_id: int, user_id: int,
) -> list[POIDistance]: ) -> list[POIDistance]:
t0 = time.monotonic()
with Session(self.engine) as session: with Session(self.engine) as session:
# Join with POI to filter by user # Join with POI to filter by user
statement = ( statement = (
@ -106,7 +114,9 @@ class POIRepository:
PointOfInterest.user_id == user_id, 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]: def get_distances_for_poi(self, poi_id: int) -> list[POIDistance]:
with Session(self.engine) as session: with Session(self.engine) as session:

View file

@ -3,16 +3,25 @@ import hashlib
import json import json
import logging import logging
import os import os
import time
import uuid import uuid
from typing import Generator from typing import Generator
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
import redis import redis
import api.metrics as app_metrics
from models.listing import QueryParameters from models.listing import QueryParameters
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _record_cache_op(operation: str, duration: float) -> None:
"""Record a cache operation timing metric, no-op if metrics aren't initialized."""
if app_metrics._meter is None:
return
app_metrics.cache_operation_duration_seconds.record(duration, {"operation": operation})
CACHE_PREFIX = "listings:geojson:" CACHE_PREFIX = "listings:geojson:"
STAGING_PREFIX = "listings:geojson:staging:" STAGING_PREFIX = "listings:geojson:staging:"
CACHE_TTL_SECONDS = 24 * 60 * 60 # 24 hours CACHE_TTL_SECONDS = 24 * 60 * 60 # 24 hours
@ -40,11 +49,15 @@ def make_cache_key(query_params: QueryParameters) -> str:
def get_cached_count(query_params: QueryParameters) -> int | None: def get_cached_count(query_params: QueryParameters) -> int | None:
"""Return the number of cached features for a query, or None if not cached.""" """Return the number of cached features for a query, or None if not cached."""
try: try:
t0 = time.monotonic()
client = _get_redis_client() client = _get_redis_client()
key = make_cache_key(query_params) key = make_cache_key(query_params)
if not client.exists(key): if not client.exists(key):
_record_cache_op("check", time.monotonic() - t0)
return None return None
return client.llen(key) count = client.llen(key)
_record_cache_op("check", time.monotonic() - t0)
return count
except redis.RedisError as e: except redis.RedisError as e:
logger.warning(f"Redis cache read error: {e}") logger.warning(f"Redis cache read error: {e}")
return None return None
@ -61,7 +74,9 @@ def get_cached_features(
for start in range(0, total, batch_size): for start in range(0, total, batch_size):
end = start + batch_size - 1 end = start + batch_size - 1
t0 = time.monotonic()
items = client.lrange(key, start, end) items = client.lrange(key, start, end)
_record_cache_op("read_batch", time.monotonic() - t0)
batch = [json.loads(item) for item in items] batch = [json.loads(item) for item in items]
if batch: if batch:
yield batch yield batch
@ -100,12 +115,14 @@ def cache_features_batch_staged(staging_key: str, features: list[dict]) -> None:
if not features: if not features:
return return
try: try:
t0 = time.monotonic()
client = _get_redis_client() client = _get_redis_client()
pipeline = client.pipeline() pipeline = client.pipeline()
for feature in features: for feature in features:
pipeline.rpush(staging_key, json.dumps(feature)) pipeline.rpush(staging_key, json.dumps(feature))
pipeline.expire(staging_key, STAGING_TTL_SECONDS) pipeline.expire(staging_key, STAGING_TTL_SECONDS)
pipeline.execute() pipeline.execute()
_record_cache_op("write_batch", time.monotonic() - t0)
except redis.RedisError as e: except redis.RedisError as e:
logger.warning(f"Redis staged cache write error: {e}") logger.warning(f"Redis staged cache write error: {e}")
@ -113,11 +130,13 @@ def cache_features_batch_staged(staging_key: str, features: list[dict]) -> None:
def finalize_cache_population(staging_key: str, query_params: QueryParameters) -> None: def finalize_cache_population(staging_key: str, query_params: QueryParameters) -> None:
"""Atomically rename the staging key to the live cache key and set TTL.""" """Atomically rename the staging key to the live cache key and set TTL."""
try: try:
t0 = time.monotonic()
client = _get_redis_client() client = _get_redis_client()
live_key = make_cache_key(query_params) live_key = make_cache_key(query_params)
# RENAME is atomic — replaces the live key in one operation # RENAME is atomic — replaces the live key in one operation
client.rename(staging_key, live_key) client.rename(staging_key, live_key)
client.expire(live_key, CACHE_TTL_SECONDS) client.expire(live_key, CACHE_TTL_SECONDS)
_record_cache_op("finalize", time.monotonic() - t0)
logger.debug(f"Finalized cache population for {live_key}") logger.debug(f"Finalized cache population for {live_key}")
except redis.RedisError as e: except redis.RedisError as e:
logger.warning(f"Redis cache finalize error: {e}") logger.warning(f"Redis cache finalize error: {e}")