Skip to content

Commit b1cbd50

Browse files
authored
feat(observability): add business payment metrics and alerts (#243)
Closes #214
1 parent 6289650 commit b1cbd50

8 files changed

Lines changed: 228 additions & 20 deletions

File tree

backend/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/monitoring/alerts.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,27 @@ groups:
4040
severity: critical
4141
annotations:
4242
summary: BLINKS backend Prometheus scrape target is down
43+
44+
- alert: PaymentSuccessRateLow
45+
expr: (sum(rate(payment_transactions_total{status="completed"}[10m])) / clamp_min(sum(rate(payment_transactions_total{status="created"}[10m])), 1)) < 0.9
46+
for: 10m
47+
labels:
48+
severity: critical
49+
annotations:
50+
summary: BLINKS payment success rate is below 90% over 10m
51+
52+
- alert: MerchantPaymentSuccessRateLow
53+
expr: (sum(rate(payment_transactions_total{status="completed"}[10m])) by (merchant_id) / clamp_min(sum(rate(payment_transactions_total{status="created"}[10m])) by (merchant_id), 1)) < 0.9
54+
for: 10m
55+
labels:
56+
severity: warning
57+
annotations:
58+
summary: BLINKS merchant payment success rate is below 90% over 10m
59+
60+
- alert: NoPaymentsCreated
61+
expr: increase(payment_transactions_total{status="created"}[15m]) == 0
62+
for: 15m
63+
labels:
64+
severity: warning
65+
annotations:
66+
summary: No payments created in the last 15 minutes

backend/monitoring/grafana-dashboard.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,42 @@
7575
"expr": "db_pool_connections"
7676
}
7777
]
78+
},
79+
{
80+
"type": "timeseries",
81+
"title": "Payment volume / sec (by currency)",
82+
"targets": [
83+
{
84+
"expr": "sum(rate(payment_volume_total[5m])) by (currency)"
85+
}
86+
]
87+
},
88+
{
89+
"type": "stat",
90+
"title": "Payment success rate (global, 10m)",
91+
"targets": [
92+
{
93+
"expr": "100 * (sum(rate(payment_transactions_total{status=\"completed\"}[10m])) / clamp_min(sum(rate(payment_transactions_total{status=\"created\"}[10m])), 1))"
94+
}
95+
]
96+
},
97+
{
98+
"type": "timeseries",
99+
"title": "Merchant payment success rate (10m)",
100+
"targets": [
101+
{
102+
"expr": "100 * (sum(rate(payment_transactions_total{status=\"completed\"}[10m])) by (merchant_id) / clamp_min(sum(rate(payment_transactions_total{status=\"created\"}[10m])) by (merchant_id), 1))"
103+
}
104+
]
105+
},
106+
{
107+
"type": "timeseries",
108+
"title": "Payment processing duration p95",
109+
"targets": [
110+
{
111+
"expr": "histogram_quantile(0.95, sum(rate(payment_processing_duration_seconds_bucket[5m])) by (le, payment_method, status))"
112+
}
113+
]
78114
}
79115
]
80116
}

backend/src/db.rs

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ use std::str::FromStr;
33
use tokio_postgres::NoTls;
44
use tokio::time::{sleep, Duration};
55
use crate::service::MetricsService;
6-
use std::sync::Arc;
76
use std::cmp;
87

98
pub type DbPool = Pool;
@@ -56,10 +55,9 @@ pub fn start_db_pool_monitoring(
5655
) -> tokio::task::JoinHandle<()> {
5756
tokio::spawn(async move {
5857
loop {
59-
match tokio_postgres::Config::from_str(&database_url)
60-
.and_then(|cfg| cfg.connect(NoTls))
61-
{
62-
Ok((client, connection)) => {
58+
match tokio_postgres::Config::from_str(&database_url) {
59+
Ok(cfg) => match cfg.connect(NoTls).await {
60+
Ok((client, connection)) => {
6361
// detach connection handling
6462
tokio::spawn(async move {
6563
if let Err(e) = connection.await {
@@ -83,9 +81,13 @@ pub fn start_db_pool_monitoring(
8381
Err(e) => tracing::warn!(error = %e, "Failed to query pg_stat_activity"),
8482
}
8583

86-
let _ = client.close().await;
8784
}
88-
Err(e) => tracing::error!(error = %e, "Failed to connect to Postgres for monitoring"),
85+
Err(e) => tracing::error!(
86+
error = %e,
87+
"Failed to connect to Postgres for monitoring"
88+
),
89+
},
90+
Err(e) => tracing::error!(error = %e, "Invalid Postgres config for monitoring"),
8991
}
9092

9193
sleep(Duration::from_secs(check_interval_secs)).await;
@@ -95,20 +97,19 @@ pub fn start_db_pool_monitoring(
9597

9698
/// Health check for database connectivity.
9799
pub async fn health_check_db(database_url: &str) -> bool {
98-
match tokio_postgres::Config::from_str(database_url)
99-
.and_then(|cfg| cfg.connect(NoTls))
100-
.await
101-
{
102-
Ok((mut client, connection)) => {
100+
match tokio_postgres::Config::from_str(database_url) {
101+
Ok(cfg) => match cfg.connect(NoTls).await {
102+
Ok((mut client, connection)) => {
103103
// drive connection
104104
tokio::spawn(async move {
105105
let _ = connection.await;
106106
});
107107

108108
let res = client.query_one("SELECT 1", &[]).await.is_ok();
109-
let _ = client.close().await;
110109
res
111110
}
111+
Err(_) => false,
112+
},
112113
Err(_) => false,
113114
}
114115
}

backend/src/http/metrics.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ pub async fn json_metrics(State(services): State<Arc<ServiceContainer>>) -> Json
6464
let status = services.db_pool.status();
6565
let db_pool_size = status.size;
6666
// active connections = size - available (deadpool status exposes `available`)
67-
let active_connections = db_pool_size.saturating_sub(status.available);
67+
let available = usize::try_from(status.available).unwrap_or(0);
68+
let active_connections = db_pool_size.saturating_sub(available);
6869
MetricsService::update_db_pool_status(db_pool_size, active_connections);
6970

7071
let detailed = MetricsService::get_detailed_metrics();

backend/src/http/payments.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use axum::{
44
};
55
use serde::{Deserialize, Serialize};
66
use std::sync::Arc;
7+
use std::time::Instant;
78
use uuid::Uuid;
89

910
use crate::{
@@ -77,6 +78,7 @@ pub async fn create_payment(
7778
State(services): State<Arc<ServiceContainer>>,
7879
Json(request): Json<CreatePaymentRequest>,
7980
) -> Result<Json<PaymentResponse>, ApiError> {
81+
let start = Instant::now();
8082
// Get user from auth context (would need to implement proper auth extraction)
8183
// For now, using a placeholder address
8284
let from_address = "GEXAMPLE_ADDRESS".to_string();
@@ -131,6 +133,14 @@ pub async fn create_payment(
131133
.await;
132134

133135
MetricsService::record_business_event("payment", "created");
136+
MetricsService::record_payment_transaction(
137+
&payment.merchant_id,
138+
"created",
139+
"api",
140+
&payment.send_asset,
141+
payment.send_amount,
142+
);
143+
MetricsService::record_payment_processing_duration("api", "created", start.elapsed().as_secs_f64());
134144

135145
Ok(Json(PaymentResponse {
136146
id: Uuid::parse_str(&payment.id).unwrap_or_default(),

backend/src/service/indexer_service.rs

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::api_error::ApiError;
22
use crate::config::Config;
3+
use crate::service::MetricsService;
34
use chrono::{DateTime, Utc};
45
use deadpool_postgres::Pool;
56
use reqwest::Client;
@@ -282,16 +283,37 @@ impl IndexerService {
282283

283284
// Update corresponding payment record if it exists
284285
if let Some(_dest) = destination {
285-
let update_result = client
286-
.execute(
287-
"UPDATE payments SET tx_hash = $1, status = 'completed', updated_at = NOW()
288-
WHERE from_address = $2 AND status = 'processing'
286+
// Find the most recent matching payment and update it by id to avoid UPDATE ... LIMIT
287+
let payment_row = client
288+
.query_opt(
289+
"SELECT id, merchant_id, send_asset, send_amount, created_at
290+
FROM payments
291+
WHERE from_address = $1 AND status IN ('pending','processing')
292+
ORDER BY created_at DESC
289293
LIMIT 1",
290-
&[&tx_hash, &source],
294+
&[&source],
291295
)
292296
.await?;
293297

294-
if update_result > 0 {
298+
if let Some(payment_row) = payment_row {
299+
let payment_id: String = payment_row.get("id");
300+
let merchant_id: String = payment_row.get("merchant_id");
301+
let send_asset: String = payment_row.get("send_asset");
302+
let send_amount: i64 = payment_row.get("send_amount");
303+
let created_at: chrono::DateTime<chrono::Utc> = payment_row.get("created_at");
304+
305+
let update_result = client
306+
.execute(
307+
"UPDATE payments SET tx_hash = $1, status = 'completed', updated_at = NOW()
308+
WHERE id = $2",
309+
&[&tx_hash, &payment_id],
310+
)
311+
.await?;
312+
313+
if update_result == 0 {
314+
continue;
315+
}
316+
295317
// Mark event as processed
296318
client
297319
.execute(
@@ -301,6 +323,20 @@ impl IndexerService {
301323
.await?;
302324

303325
processed += 1;
326+
let duration_secs =
327+
(chrono::Utc::now() - created_at).num_milliseconds() as f64 / 1000.0;
328+
MetricsService::record_payment_transaction(
329+
&merchant_id,
330+
"completed",
331+
"stellar_indexer",
332+
&send_asset,
333+
send_amount,
334+
);
335+
MetricsService::record_payment_processing_duration(
336+
"stellar_indexer",
337+
"completed",
338+
duration_secs.max(0.0),
339+
);
304340
info!(tx_hash = %tx_hash, "Payment event processed");
305341
}
306342
}

backend/src/service/metrics_service.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use prometheus::{
44
register_histogram_vec, CounterVec, Encoder, Gauge, GaugeVec, HistogramVec, TextEncoder,
55
};
66
use serde::Serialize;
7+
use dashmap::DashMap;
78
use std::sync::atomic::{AtomicU64, Ordering};
89
use std::time::{SystemTime, UNIX_EPOCH};
910

@@ -144,13 +145,24 @@ lazy_static! {
144145
)
145146
.expect("Can't create payment_processing_duration_seconds metric");
146147

148+
/// Payment transaction lifecycle counter
149+
pub static ref PAYMENT_TRANSACTIONS_TOTAL: CounterVec = register_counter_vec!(
150+
"payment_transactions_total",
151+
"Total payment transactions by merchant, status, method and currency",
152+
&["merchant_id", "status", "payment_method", "currency"]
153+
)
154+
.expect("Can't create payment_transactions_total metric");
155+
147156
/// Application start time (Unix timestamp)
148157
static ref APP_START_TIME: AtomicU64 = AtomicU64::new(
149158
SystemTime::now()
150159
.duration_since(UNIX_EPOCH)
151160
.unwrap()
152161
.as_secs()
153162
);
163+
164+
/// In-memory counters for computing success rate gauge (process lifetime).
165+
static ref PAYMENT_OUTCOME_COUNTS: DashMap<String, (u64, u64, u64)> = DashMap::new();
154166
}
155167

156168
/// Metrics payload as specified in the issue
@@ -275,6 +287,7 @@ pub struct AlertPayload {
275287
let _ = &*PAYMENT_VOLUME_TOTAL;
276288
let _ = &*PAYMENT_SUCCESS_RATE;
277289
let _ = &*PAYMENT_PROCESSING_DURATION_SECONDS;
290+
let _ = &*PAYMENT_TRANSACTIONS_TOTAL;
278291

279292
tracing::info!("Metrics service initialized");
280293
}
@@ -304,6 +317,55 @@ pub struct AlertPayload {
304317
.observe(duration_secs);
305318
}
306319

320+
fn currency_label_from_asset(asset: &str) -> String {
321+
if asset.eq_ignore_ascii_case("XLM") {
322+
return "XLM".to_string();
323+
}
324+
325+
// Expected format: CODE:ISSUER
326+
asset.split(':').next().unwrap_or(asset).to_string()
327+
}
328+
329+
/// Record a payment lifecycle event and refresh derived gauges.
330+
pub fn record_payment_transaction(
331+
merchant_id: &str,
332+
status: &str,
333+
payment_method: &str,
334+
send_asset: &str,
335+
amount: i64,
336+
) {
337+
let currency = Self::currency_label_from_asset(send_asset);
338+
339+
PAYMENT_TRANSACTIONS_TOTAL
340+
.with_label_values(&[merchant_id, status, payment_method, &currency])
341+
.inc();
342+
343+
// Treat volume as "requested" unless status indicates a finalized successful payment.
344+
if status == "created" || status == "completed" {
345+
Self::record_payment_volume(merchant_id, &currency, amount as f64);
346+
}
347+
348+
let mut entry = PAYMENT_OUTCOME_COUNTS
349+
.entry(merchant_id.to_string())
350+
.or_insert((0, 0, 0));
351+
352+
match status {
353+
"created" => entry.value_mut().0 = entry.value().0.saturating_add(1),
354+
"completed" => entry.value_mut().1 = entry.value().1.saturating_add(1),
355+
"failed" => entry.value_mut().2 = entry.value().2.saturating_add(1),
356+
_ => {}
357+
}
358+
359+
let (created, completed, failed) = *entry.value();
360+
let denom = created.saturating_add(failed);
361+
let success_rate_percent = if denom == 0 {
362+
0.0
363+
} else {
364+
(completed as f64 / denom as f64) * 100.0
365+
};
366+
Self::update_payment_success_rate(merchant_id, success_rate_percent);
367+
}
368+
307369
/// Get application uptime in seconds
308370
pub fn get_uptime() -> u64 {
309371
let start_time = APP_START_TIME.load(Ordering::Relaxed);
@@ -607,6 +669,43 @@ pub struct AlertPayload {
607669
}
608670
}
609671

672+
#[cfg(test)]
673+
mod payment_metrics_tests {
674+
use super::*;
675+
676+
#[test]
677+
fn record_payment_transaction_updates_counters_and_success_rate() {
678+
MetricsService::init();
679+
680+
MetricsService::record_payment_transaction("m1", "created", "api", "XLM", 100);
681+
MetricsService::record_payment_transaction("m1", "completed", "stellar_indexer", "XLM", 100);
682+
683+
// Counter exists and has been incremented.
684+
let families = PAYMENT_TRANSACTIONS_TOTAL.collect();
685+
let total: f64 = families
686+
.first()
687+
.unwrap()
688+
.get_metric()
689+
.iter()
690+
.map(|m| m.get_counter().get_value())
691+
.sum();
692+
assert!(total >= 2.0);
693+
694+
// Derived gauge is updated for merchant.
695+
let gauge_families = PAYMENT_SUCCESS_RATE.collect();
696+
let merchant_gauge = gauge_families
697+
.first()
698+
.unwrap()
699+
.get_metric()
700+
.iter()
701+
.find(|m| m.get_label().iter().any(|l| l.get_name() == "merchant_id" && l.get_value() == "m1"))
702+
.unwrap()
703+
.get_gauge()
704+
.get_value();
705+
assert!(merchant_gauge > 0.0);
706+
}
707+
}
708+
610709
#[cfg(test)]
611710
mod tests {
612711
use super::*;

0 commit comments

Comments
 (0)