Skip to content

Commit 44a674b

Browse files
authored
Merge pull request #364 from popsman01/feature/issue-81-performance-monitoring
feat(monitoring): implement APM performance monitoring system
2 parents c70f358 + a66a2ba commit 44a674b

10 files changed

Lines changed: 425 additions & 3 deletions

File tree

PERFORMANCE.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,46 @@ Metrics are collected at three layers:
9494
| Layer | Mechanism | Endpoint |
9595
|-------|-----------|---------|
9696
| Infrastructure | `HealthMonitor` (Rust) | `GET /health/metrics` |
97+
| APM (request-level) | `PerformanceService` (Rust) | `GET /performance/dashboard` |
98+
| APM alerts | `PerformanceService` alert rules | `GET /performance/alerts` |
99+
| APM history | Hourly rollup (materialized view) | `GET /performance/history` |
100+
| Prometheus scrape | `MonitoringService` + APM metrics | `GET /metrics` |
97101
| Web Vitals | `observeWebVitals()` (JS) | beacons → `POST /api/metrics` |
98102
| CDN | `observeCdnPerformance()` (JS) | beacons → `POST /api/metrics` |
99103
| SSL | `monitorTlsConnection()` (JS) | beacons → `POST /api/metrics` |
100104

105+
### APM Endpoints
106+
107+
| Endpoint | Description |
108+
|----------|-------------|
109+
| `GET /performance/dashboard` | Full APM snapshot: per-route stats, overall P95/P99, active alerts |
110+
| `GET /performance/alerts` | Active performance alerts only |
111+
| `GET /performance/history` | Hourly rollup from `perf_metrics_hourly` (last 24h) |
112+
| `POST /performance/record` | Ingest a sample `{ route, method, status, duration_ms }` |
113+
| `GET /metrics` | Prometheus text format (monitoring + APM metrics) |
114+
115+
### Grafana Dashboards
116+
117+
| Dashboard | UID | Description |
118+
|-----------|-----|-------------|
119+
| StellarEscrow Platform | `stellar-escrow-main` | Business metrics (trades, compliance, fraud) |
120+
| APM — Performance | `stellar-escrow-apm` | Latency (avg/P95/P99), error rate, throughput, DB query time |
121+
| Code Quality | `code-quality` | CI lint/security metrics |
122+
123+
### Alert Rules
124+
125+
| File | Group | Alerts |
126+
|------|-------|--------|
127+
| `alert_rules.yml` | `stellar_escrow_platform` | Error rate, disputes, fraud |
128+
| `alert_rules_security.yml` | `stellar_escrow_compliance` | AML, compliance blocks |
129+
| `alert_rules_performance.yml` | `stellar_escrow_performance` | Latency (avg/P95), error rate, DB queries, throughput |
130+
101131
### Key Metrics to Watch
102132

103133
- **TTFB** < 200ms (good), < 800ms (acceptable)
104134
- **LCP** < 2.5s
135+
- **Avg response time** < 500ms (warning at 500ms, critical at 2000ms)
136+
- **P95 response time** < 1000ms (warning at 1000ms, critical at 5000ms)
105137
- **DB query mean** < 10ms for hot paths (`get_events`, `search_trades`)
106138
- **Redis hit rate** > 80% under normal load
107139
- **Indexer memory** < 256MB under normal load

docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ services:
202202
- "127.0.0.1:9090:9090"
203203
volumes:
204204
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
205+
- ./monitoring/alert_rules.yml:/etc/prometheus/alert_rules.yml:ro
206+
- ./monitoring/alert_rules_security.yml:/etc/prometheus/alert_rules_security.yml:ro
207+
- ./monitoring/alert_rules_performance.yml:/etc/prometheus/alert_rules_performance.yml:ro
205208
- prometheus_data:/prometheus
206209
command:
207210
- "--config.file=/etc/prometheus/prometheus.yml"
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
-- APM query helpers: materialized view + index for fast dashboard queries
2+
3+
-- Hourly rollup view for performance metrics
4+
CREATE MATERIALIZED VIEW IF NOT EXISTS perf_metrics_hourly AS
5+
SELECT
6+
date_trunc('hour', recorded_at) AS hour,
7+
route,
8+
method,
9+
COUNT(*) AS requests,
10+
SUM(CASE WHEN is_error THEN 1 ELSE 0 END) AS errors,
11+
AVG(duration_ms) AS avg_ms,
12+
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95_ms,
13+
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY duration_ms) AS p99_ms
14+
FROM performance_metrics
15+
GROUP BY 1, 2, 3
16+
WITH NO DATA;
17+
18+
CREATE UNIQUE INDEX IF NOT EXISTS idx_perf_hourly_pk
19+
ON perf_metrics_hourly (hour DESC, route, method);
20+
21+
-- Refresh function (called by background job or cron)
22+
CREATE OR REPLACE FUNCTION refresh_perf_hourly()
23+
RETURNS void LANGUAGE sql AS $$
24+
REFRESH MATERIALIZED VIEW CONCURRENTLY perf_metrics_hourly;
25+
$$;
26+
27+
-- Index to speed up alert history queries
28+
CREATE INDEX IF NOT EXISTS idx_perf_alerts_unresolved
29+
ON performance_alerts (triggered_at DESC)
30+
WHERE resolved_at IS NULL;

indexer/src/database.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1155,7 +1155,43 @@ impl Database {
11551155
.execute(&self.pool)
11561156
.await?;
11571157
Ok(())
1158-
pub async fn get_integration_deliveries(
1158+
}
1159+
1160+
/// Query the hourly APM rollup materialized view for the last `hours` hours.
1161+
pub async fn get_perf_hourly_rollup(
1162+
&self,
1163+
hours: i64,
1164+
) -> Result<Vec<serde_json::Value>, sqlx::Error> {
1165+
let rows = sqlx::query(
1166+
r#"
1167+
SELECT hour, route, method, requests, errors, avg_ms, p95_ms, p99_ms
1168+
FROM perf_metrics_hourly
1169+
WHERE hour >= NOW() - ($1 || ' hours')::INTERVAL
1170+
ORDER BY hour DESC
1171+
LIMIT 500
1172+
"#,
1173+
)
1174+
.bind(hours)
1175+
.fetch_all(&self.pool)
1176+
.await?;
1177+
1178+
Ok(rows
1179+
.into_iter()
1180+
.map(|r| {
1181+
use sqlx::Row;
1182+
serde_json::json!({
1183+
"hour": r.get::<chrono::DateTime<chrono::Utc>, _>("hour"),
1184+
"route": r.get::<String, _>("route"),
1185+
"method": r.get::<String, _>("method"),
1186+
"requests": r.get::<i64, _>("requests"),
1187+
"errors": r.get::<i64, _>("errors"),
1188+
"avg_ms": r.get::<f64, _>("avg_ms"),
1189+
"p95_ms": r.get::<f64, _>("p95_ms"),
1190+
"p99_ms": r.get::<f64, _>("p99_ms"),
1191+
})
1192+
})
1193+
.collect())
1194+
}
11591195
&self,
11601196
connector_id: Option<&str>,
11611197
limit: i64,

indexer/src/handlers.rs

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,34 @@ pub async fn get_performance_alerts(
569569
}))
570570
}
571571

572+
#[derive(serde::Deserialize)]
573+
pub struct PerfRecordBody {
574+
pub route: String,
575+
pub method: String,
576+
pub status: u16,
577+
pub duration_ms: u64,
578+
}
579+
580+
/// POST /performance/record — ingest a single APM sample (used by middleware / external agents).
581+
pub async fn record_performance_sample(
582+
State(state): State<AppState>,
583+
Json(body): Json<PerfRecordBody>,
584+
) -> StatusCode {
585+
state
586+
.performance_service
587+
.record(&body.route, &body.method, body.status, body.duration_ms)
588+
.await;
589+
StatusCode::NO_CONTENT
590+
}
591+
592+
/// GET /performance/history — hourly rollup from the materialized view.
593+
pub async fn get_performance_history(
594+
State(state): State<AppState>,
595+
) -> Result<Json<serde_json::Value>, AppError> {
596+
let rows = state.database.get_perf_hourly_rollup(24).await?;
597+
Ok(Json(serde_json::json!({ "hours": rows })))
598+
}
599+
572600
// =============================================================================
573601
// Analytics Handlers
574602
// =============================================================================
@@ -854,11 +882,42 @@ pub async fn get_monitoring_alerts(
854882
Ok(Json(serde_json::to_value(&alerts).unwrap_or_default()))
855883
}
856884

857-
/// GET /monitoring/metrics — Prometheus-format metrics.
885+
/// GET /monitoring/metrics — Prometheus-format metrics (monitoring + APM).
858886
pub async fn get_prometheus_metrics(
859887
State(state): State<AppState>,
860888
) -> axum::response::Response<String> {
861-
let body = state.monitoring_service.prometheus_metrics();
889+
let mut body = state.monitoring_service.prometheus_metrics();
890+
891+
// Append APM metrics from the performance service
892+
let dash = state.performance_service.dashboard().await;
893+
let apm = format!(
894+
"\n# HELP stellar_escrow_api_avg_response_ms Average API response time in ms\n\
895+
# TYPE stellar_escrow_api_avg_response_ms gauge\n\
896+
stellar_escrow_api_avg_response_ms {avg}\n\
897+
# HELP stellar_escrow_api_p95_response_ms P95 API response time in ms\n\
898+
# TYPE stellar_escrow_api_p95_response_ms gauge\n\
899+
stellar_escrow_api_p95_response_ms {p95}\n\
900+
# HELP stellar_escrow_api_p99_response_ms P99 API response time in ms\n\
901+
# TYPE stellar_escrow_api_p99_response_ms gauge\n\
902+
stellar_escrow_api_p99_response_ms {p99}\n\
903+
# HELP stellar_escrow_api_requests_total Total API requests recorded\n\
904+
# TYPE stellar_escrow_api_requests_total counter\n\
905+
stellar_escrow_api_requests_total {total}\n\
906+
# HELP stellar_escrow_api_requests_per_minute API requests per minute\n\
907+
# TYPE stellar_escrow_api_requests_per_minute gauge\n\
908+
stellar_escrow_api_requests_per_minute {rpm}\n\
909+
# HELP stellar_escrow_active_perf_alerts Number of active performance alerts\n\
910+
# TYPE stellar_escrow_active_perf_alerts gauge\n\
911+
stellar_escrow_active_perf_alerts {alerts}\n",
912+
avg = dash.overall.avg_ms,
913+
p95 = dash.overall.p95_ms,
914+
p99 = dash.overall.p99_ms,
915+
total = dash.overall.total_requests,
916+
rpm = dash.overall.requests_per_minute,
917+
alerts = dash.active_alerts.len(),
918+
);
919+
body.push_str(&apm);
920+
862921
axum::response::Response::builder()
863922
.status(200)
864923
.header("Content-Type", "text/plain; version=0.0.4")

indexer/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
292292
// Performance monitoring
293293
.route("/performance/dashboard", get(get_performance_dashboard))
294294
.route("/performance/alerts", get(get_performance_alerts))
295+
.route("/performance/record", post(record_performance_sample))
296+
.route("/performance/history", get(get_performance_history))
295297
// Compliance
296298
.route("/compliance/check", post(run_compliance_check))
297299
.route("/compliance/status/:address", get(get_compliance_status))

indexer/src/monitoring_service/metrics.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,11 @@ pub const METRIC_API_REQUEST_DURATION_MS: &str = "stellar_escrow_api_request_dur
9898
pub const METRIC_WEBSOCKET_CONNECTIONS: &str = "stellar_escrow_websocket_connections";
9999
pub const METRIC_FRAUD_ALERTS: &str = "stellar_escrow_fraud_alerts_total";
100100
pub const METRIC_ERROR_RATE: &str = "stellar_escrow_error_rate";
101+
102+
// APM-specific metrics exposed to Prometheus
103+
pub const METRIC_API_AVG_RESPONSE_MS: &str = "stellar_escrow_api_avg_response_ms";
104+
pub const METRIC_API_P95_RESPONSE_MS: &str = "stellar_escrow_api_p95_response_ms";
105+
pub const METRIC_API_P99_RESPONSE_MS: &str = "stellar_escrow_api_p99_response_ms";
106+
pub const METRIC_API_REQUESTS_TOTAL: &str = "stellar_escrow_api_requests_total";
107+
pub const METRIC_API_REQUESTS_PER_MINUTE: &str = "stellar_escrow_api_requests_per_minute";
108+
pub const METRIC_ACTIVE_PERF_ALERTS: &str = "stellar_escrow_active_perf_alerts";
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
groups:
2+
- name: stellar_escrow_performance
3+
rules:
4+
- alert: HighAvgLatency
5+
expr: stellar_escrow_api_avg_response_ms > 500
6+
for: 2m
7+
labels:
8+
severity: warning
9+
annotations:
10+
summary: "High average API latency"
11+
description: "Average response time is {{ $value }}ms (threshold: 500ms)."
12+
13+
- alert: CriticalAvgLatency
14+
expr: stellar_escrow_api_avg_response_ms > 2000
15+
for: 1m
16+
labels:
17+
severity: critical
18+
annotations:
19+
summary: "Critical average API latency"
20+
description: "Average response time is {{ $value }}ms (threshold: 2000ms)."
21+
22+
- alert: HighP95Latency
23+
expr: stellar_escrow_api_p95_response_ms > 1000
24+
for: 2m
25+
labels:
26+
severity: warning
27+
annotations:
28+
summary: "High P95 API latency"
29+
description: "P95 response time is {{ $value }}ms (threshold: 1000ms)."
30+
31+
- alert: CriticalP95Latency
32+
expr: stellar_escrow_api_p95_response_ms > 5000
33+
for: 1m
34+
labels:
35+
severity: critical
36+
annotations:
37+
summary: "Critical P95 API latency"
38+
description: "P95 response time is {{ $value }}ms (threshold: 5000ms)."
39+
40+
- alert: HighAPIErrorRate
41+
expr: stellar_escrow_error_rate > 5
42+
for: 2m
43+
labels:
44+
severity: warning
45+
annotations:
46+
summary: "High API error rate"
47+
description: "Error rate is {{ $value }}% (threshold: 5%)."
48+
49+
- alert: CriticalAPIErrorRate
50+
expr: stellar_escrow_error_rate > 20
51+
for: 1m
52+
labels:
53+
severity: critical
54+
annotations:
55+
summary: "Critical API error rate"
56+
description: "Error rate is {{ $value }}% (threshold: 20%)."
57+
58+
- alert: SlowDBQueries
59+
expr: stellar_escrow_db_query_duration_ms > 500
60+
for: 3m
61+
labels:
62+
severity: warning
63+
annotations:
64+
summary: "Slow database queries detected"
65+
description: "DB query duration is {{ $value }}ms (threshold: 500ms)."
66+
67+
- alert: LowThroughput
68+
expr: stellar_escrow_api_requests_per_minute < 1
69+
for: 5m
70+
labels:
71+
severity: warning
72+
annotations:
73+
summary: "Very low API throughput"
74+
description: "Requests per minute dropped to {{ $value }}."

0 commit comments

Comments
 (0)